Merge pull request #51 from sr-gi/13-appointment-signature

Add Pisa's signature to appointments
This commit is contained in:
Salvatore Ingala
2019-10-28 12:47:58 +07:00
committed by GitHub
13 changed files with 298 additions and 43 deletions

2
.gitignore vendored
View File

@@ -12,3 +12,5 @@ appointments/
test.py
*.pyc
.cache
.pytest_cache/
*.pem

View File

@@ -1,18 +1,24 @@
import logging
from apps.cli.logger import Logger
# PISA-SERVER
DEFAULT_PISA_API_SERVER = 'btc.pisa.watch'
DEFAULT_PISA_API_PORT = 9814
# PISA-CLI
CLIENT_LOG_FILE = 'pisa.log'
CLIENT_LOG_FILE = 'pisa-cli.log'
APPOINTMENTS_FOLDER_NAME = 'appointments'
# CRYPTO
SUPPORTED_HASH_FUNCTIONS = ["SHA256"]
SUPPORTED_CIPHERS = ["AES-GCM-128"]
PISA_PUBLIC_KEY = "pisa_pk.pem"
# Configure logging
logging.basicConfig(format='%(message)s', level=logging.INFO, handlers=[
logging.FileHandler(CLIENT_LOG_FILE),
logging.StreamHandler()
])
logger = Logger("Client")

View File

@@ -4,9 +4,7 @@ from binascii import hexlify, unhexlify
from cryptography.hazmat.primitives.ciphers.aead import AESGCM
from apps.cli import SUPPORTED_HASH_FUNCTIONS, SUPPORTED_CIPHERS
from pisa.logger import Logger
logger = Logger("Client")
from apps.cli import logger
class Blob:

33
apps/cli/logger.py Normal file
View File

@@ -0,0 +1,33 @@
import logging
import json
import time
class StructuredMessage(object):
def __init__(self, message, **kwargs):
self.message = message
self.time = time.asctime()
self.kwargs = kwargs
def __str__(self):
return json.dumps({**self.kwargs, "message": self.message, "time": self.time})
class Logger(object):
def __init__(self, actor=None):
self.actor = actor
def _add_prefix(self, msg):
return msg if self.actor is None else "[{}] {}".format(self.actor, msg)
def info(self, msg, **kwargs):
logging.info(StructuredMessage(self._add_prefix(msg), actor=self.actor, **kwargs))
def debug(self, msg, **kwargs):
logging.debug(StructuredMessage(self._add_prefix(msg), actor=self.actor, **kwargs))
def error(self, msg, **kwargs):
logging.error(StructuredMessage(self._add_prefix(msg), actor=self.actor, **kwargs))
def warning(self, msg, **kwargs):
logging.warning(StructuredMessage(self._add_prefix(msg), actor=self.actor, **kwargs))

View File

@@ -3,19 +3,28 @@ import os
import sys
import json
import requests
import time
from sys import argv
from hashlib import sha256
from binascii import 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.asymmetric import ec
from cryptography.exceptions import InvalidSignature, UnsupportedAlgorithm
from pisa.logger import Logger
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
from apps.cli import DEFAULT_PISA_API_SERVER, DEFAULT_PISA_API_PORT, PISA_PUBLIC_KEY, APPOINTMENTS_FOLDER_NAME
from apps.cli import logger
logger = Logger("Client")
HTTP_OK = 200
# FIXME: TESTING ENDPOINT, WON'T BE THERE IN PRODUCTION
@@ -35,6 +44,47 @@ def generate_dummy_appointment():
print('\nData stored in dummy_appointment_data.json')
# Loads Pisa's public key from disk and verifies that the appointment signature is a valid signature from Pisa,
# returning True or False accordingly.
# 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 is_appointment_signature_valid(appointment, signature) -> bool:
# Load the key from disk
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())
except UnsupportedAlgorithm:
raise ValueError("Could not deserialize the public key (unsupported algorithm).")
try:
sig_bytes = unhexlify(signature.encode('utf-8'))
data = json.dumps(appointment, sort_keys=True, separators=(',', ':')).encode("utf-8")
pisa_public_key.verify(sig_bytes, data, ec.ECDSA(hashes.SHA256()))
return True
except InvalidSignature:
return False
# Makes sure that the folder APPOINTMENTS_FOLDER_NAME exists, then saves the appointment and signature in it.
def save_signed_appointment(appointment, signature):
# Create the appointments directory if it doesn't already exist
try:
os.makedirs(APPOINTMENTS_FOLDER_NAME)
except FileExistsError:
# directory already exists, this is fine
pass
timestamp = int(time.time()*1000)
locator = appointment['locator']
uuid = uuid4() # prevent filename collisions
filename = "{}/appointment-{}-{}-{}.json".format(APPOINTMENTS_FOLDER_NAME, timestamp, locator, uuid)
data = {"appointment": appointment, "signature": signature}
with open(filename, "w") as f:
json.dump(data, f)
def add_appointment(args):
appointment_data = None
use_help = "Use 'help add_appointment' for help of how to use the command."
@@ -69,19 +119,64 @@ def add_appointment(args):
appointment = build_appointment(appointment_data.get('tx'), appointment_data.get('tx_id'),
appointment_data.get('start_time'), appointment_data.get('end_time'),
appointment_data.get('dispute_delta'))
appointment_json = json.dumps(appointment, sort_keys=True, separators=(',', ':'))
logger.info("Sending appointment to PISA")
try:
r = requests.post(url=add_appointment_endpoint, json=json.dumps(appointment), timeout=5)
r = requests.post(url=add_appointment_endpoint, json=appointment_json, timeout=5)
logger.info("{} (code: {}).".format(r.text, r.status_code))
response_json = r.json()
except json.JSONDecodeError:
logger.error("The response was not valid JSON.")
return
except ConnectTimeout:
logger.error("Can't connect to pisa API. Connection timeout.")
return
except ConnectionError:
logger.error("Can't connect to pisa API. Server cannot be reached.")
return
if r.status_code == HTTP_OK:
if 'signature' not in response_json:
logger.error("The response does not contain the signature of the appointment.")
else:
signature = response_json['signature']
# verify that the returned signature is valid
try:
is_sig_valid = is_appointment_signature_valid(appointment, signature)
except ValueError:
logger.error("Failed to deserialize the public key. It might be in an unsupported format.")
return
except FileNotFoundError:
logger.error("Pisa's public key file not found. Please check your settings.")
return
except IOError as e:
logger.error("I/O error({}): {}".format(e.errno, e.strerror))
return
if is_sig_valid:
logger.info("Appointment accepted and signed by Pisa.")
# all good, store appointment and signature
try:
save_signed_appointment(appointment, signature)
except OSError as e:
logger.error("There was an error while saving the appointment: {}".format(e))
else:
logger.error("The returned appointment's signature is invalid.")
else:
if 'error' not in response_json:
logger.error("The server returned status code {}, but no error description."
.format(r.status_code))
else:
error = r.json()['error']
logger.error("The server returned status code {}, and the following error: {}."
.format(r.status_code, error))
else:
logger.error("The provided locator is not valid.")
else:
@@ -119,7 +214,7 @@ def get_appointment(args):
logger.error("The provided locator is not valid.")
def build_appointment(tx, tx_id, start_block, end_block, dispute_delta):
def build_appointment(tx, tx_id, start_time, end_time, dispute_delta):
locator = sha256(unhexlify(tx_id)).hexdigest()
cipher = "AES-GCM-128"
@@ -129,9 +224,9 @@ def build_appointment(tx, tx_id, start_block, end_block, dispute_delta):
blob = Blob(tx, cipher, hash_function)
encrypted_blob = blob.encrypt(tx_id)
appointment = {"locator": locator, "start_time": start_block, "end_time": end_block,
"dispute_delta": dispute_delta, "encrypted_blob": encrypted_blob, "cipher": cipher, "hash_function":
hash_function}
appointment = {
'locator': locator, 'start_time': start_time, 'end_time': end_time, 'dispute_delta': dispute_delta,
'encrypted_blob': encrypted_blob, 'cipher': cipher, 'hash_function': hash_function}
return appointment
@@ -223,4 +318,3 @@ if __name__ == '__main__':
except json.JSONDecodeError as e:
logger.error("Non-JSON encoded appointment passed as parameter.")

47
apps/generate_key.py Normal file
View File

@@ -0,0 +1,47 @@
import os.path
from sys import exit
from cryptography.hazmat.backends import default_backend
from cryptography.hazmat.primitives import serialization
from cryptography.hazmat.primitives.asymmetric import ec
# Simple tool to generate an ECDSA private key using the secp256k1 curve and save private and public keys
# as 'pisa_sk.pem' 'and pisa_pk.pem', respectively.
SK_FILE_NAME = 'pisa_sk.pem'
PK_FILE_NAME = 'pisa_pk.pem'
def save_sk(sk, filename):
pem = sk.private_bytes(
encoding=serialization.Encoding.PEM,
format=serialization.PrivateFormat.TraditionalOpenSSL,
encryption_algorithm=serialization.NoEncryption()
)
with open(filename, 'wb') as pem_out:
pem_out.write(pem)
def save_pk(pk, filename):
pem = pk.public_bytes(
encoding=serialization.Encoding.PEM,
format=serialization.PublicFormat.SubjectPublicKeyInfo
)
with open(filename, 'wb') as pem_out:
pem_out.write(pem)
if __name__ == '__main__':
if os.path.exists(SK_FILE_NAME):
print("A key with name \"{}\" already exists. Aborting.".format(SK_FILE_NAME))
exit(1)
sk = ec.generate_private_key(
ec.SECP256K1, default_backend()
)
pk = sk.public_key()
save_sk(sk, SK_FILE_NAME)
save_pk(pk, PK_FILE_NAME)
print("Saved private key \"{}\" and public key \"{}\".".format(SK_FILE_NAME, PK_FILE_NAME))

View File

@@ -1,6 +1,7 @@
import os
import json
from flask import Flask, request, Response, abort, jsonify
from flask import Flask, request, abort, jsonify
from binascii import hexlify
from pisa import HOST, PORT, logging
from pisa.logger import Logger
@@ -31,30 +32,35 @@ def add_appointment():
request_data = json.loads(request.get_json())
appointment = inspector.inspect(request_data)
if type(appointment) == Appointment:
appointment_added = watcher.add_appointment(appointment)
error = None
response = None
if type(appointment) == Appointment:
appointment_added, signature = watcher.add_appointment(appointment)
# ToDo: #13-create-server-side-signature-receipt
if appointment_added:
rcode = HTTP_OK
response = "appointment accepted. locator: {}".format(appointment.locator)
response = {"locator": appointment.locator, "signature": hexlify(signature).decode('utf-8')}
else:
rcode = HTTP_SERVICE_UNAVAILABLE
response = "appointment rejected"
error = "appointment rejected"
elif type(appointment) == tuple:
rcode = HTTP_BAD_REQUEST
response = "appointment rejected. Error {}: {}".format(appointment[0], appointment[1])
error = "appointment rejected. Error {}: {}".format(appointment[0], appointment[1])
else:
# We should never end up here, since inspect only returns appointments or tuples. Just in case.
rcode = HTTP_BAD_REQUEST
response = "appointment rejected. Request does not match the standard"
error = "appointment rejected. Request does not match the standard"
logger.info('Sending response and disconnecting',
from_addr_port='{}:{}'.format(remote_addr, remote_port), response=response)
from_addr_port='{}:{}'.format(remote_addr, remote_port), response=response, error=error)
return Response(response, status=rcode, mimetype='text/plain')
if error is None:
return jsonify(response), rcode
else:
return jsonify({"error": error}), rcode
# FIXME: THE NEXT THREE API ENDPOINTS ARE FOR TESTING AND SHOULD BE REMOVED / PROPERLY MANAGED BEFORE PRODUCTION!
@@ -70,7 +76,7 @@ def get_appointment():
if appointment_in_watcher:
for uuid in appointment_in_watcher:
appointment_data = watcher.appointments[uuid].to_json()
appointment_data = watcher.appointments[uuid].to_dict()
appointment_data['status'] = "being_watched"
response.append(appointment_data)
@@ -79,7 +85,7 @@ def get_appointment():
for job in responder_jobs.values():
if job.locator == locator:
job_data = job.to_json()
job_data = job.to_dict()
job_data['status'] = "dispute_responded"
response.append(job_data)
@@ -100,7 +106,7 @@ def get_all_appointments():
if request.remote_addr in request.host or request.remote_addr == '127.0.0.1':
for uuid, appointment in watcher.appointments.items():
watcher_appointments[uuid] = appointment.to_json()
watcher_appointments[uuid] = appointment.to_dict()
if watcher.responder:
for uuid, job in watcher.responder.jobs.items():

View File

@@ -1,3 +1,5 @@
import json
from pisa.encrypted_blob import EncryptedBlob
@@ -13,7 +15,7 @@ class Appointment:
self.cipher = cipher
self.hash_function = hash_function
def to_json(self):
def to_dict(self):
appointment = {"locator": self.locator, "start_time": self.start_time, "end_time": self.end_time,
"dispute_delta": self.dispute_delta, "encrypted_blob": self.encrypted_blob.data,
"cipher": self.cipher, "hash_function": self.hash_function}
@@ -22,3 +24,5 @@ class Appointment:
# ToDO: #3-improve-appointment-structure
def to_json(self):
return json.dumps(self.to_dict(), sort_keys=True, separators=(',', ':'))

View File

@@ -36,6 +36,9 @@ if __name__ == '__main__':
logger.error("bitcoind is running on a different network, check conf.py and bitcoin.conf. Shutting down")
else:
# Fire the api
start_api()
try:
# Fire the api
start_api()
except Exception as e:
logger.error("An error occurred: {}. Shutting down".format(e))
exit(1)

View File

@@ -17,6 +17,7 @@ EXPIRY_DELTA = 6
MIN_DISPUTE_DELTA = 20
SERVER_LOG_FILE = 'pisa.log'
DB_PATH = 'appointments/'
PISA_SECRET_KEY = 'pisa_sk.pem'
# PISA-CLI
CLIENT_LOG_FILE = 'pisa.log'

View File

@@ -2,11 +2,15 @@ from uuid import uuid4
from queue import Queue
from threading import Thread
from cryptography.hazmat.backends import default_backend
from cryptography.hazmat.primitives import hashes
from cryptography.hazmat.primitives.serialization import load_pem_private_key
from cryptography.hazmat.primitives.asymmetric import ec
from pisa.logger import Logger
from pisa.cleaner import Cleaner
from pisa.conf import EXPIRY_DELTA
from pisa.conf import EXPIRY_DELTA, MAX_APPOINTMENTS, PISA_SECRET_KEY
from pisa.responder import Responder
from pisa.conf import MAX_APPOINTMENTS
from pisa.block_processor import BlockProcessor
from pisa.utils.zmq_subscriber import ZMQHandler
@@ -23,6 +27,17 @@ class Watcher:
self.zmq_subscriber = None
self.responder = Responder()
if PISA_SECRET_KEY is None:
raise ValueError("No signing key provided. Please fix your pisa.conf")
else:
with open(PISA_SECRET_KEY, "r") as key_file:
secret_key_pem = key_file.read().encode("utf-8")
self.signing_key = load_pem_private_key(secret_key_pem, password=None, backend=default_backend())
def sign_appointment(self, appointment):
data = appointment.to_json().encode("utf-8")
return self.signing_key.sign(data, ec.ECDSA(hashes.SHA256()))
def add_appointment(self, appointment):
# Rationale:
# The Watcher will analyze every received block looking for appointment matches. If there is no work
@@ -60,12 +75,14 @@ class Watcher:
logger.info("New appointment accepted.", locator=appointment.locator)
signature = self.sign_appointment(appointment)
else:
appointment_added = False
signature = None
logger.info("Maximum appointments reached, appointment rejected.", locator=appointment.locator)
return appointment_added
return appointment_added, signature
def do_subscribe(self):
self.zmq_subscriber = ZMQHandler(parent="Watcher")

View File

@@ -1,3 +1,4 @@
import json
from pytest import fixture
from pisa.appointment import Appointment
@@ -35,13 +36,25 @@ def test_init_appointment(appointment_data):
and dispute_delta == appointment.dispute_delta and hash_function == appointment.hash_function)
def test_to_dict(appointment_data):
locator, start_time, end_time, dispute_delta, encrypted_blob_data, cipher, hash_function = appointment_data
appointment = Appointment(locator, start_time, end_time, dispute_delta, encrypted_blob_data, cipher, hash_function)
dict_appointment = appointment.to_dict()
assert (locator == dict_appointment.get("locator") and start_time == dict_appointment.get("start_time")
and end_time == dict_appointment.get("end_time") and dispute_delta == dict_appointment.get("dispute_delta")
and cipher == dict_appointment.get("cipher") and hash_function == dict_appointment.get("hash_function")
and encrypted_blob_data == dict_appointment.get("encrypted_blob"))
def test_to_json(appointment_data):
locator, start_time, end_time, dispute_delta, encrypted_blob_data, cipher, hash_function = appointment_data
appointment = Appointment(locator, start_time, end_time, dispute_delta, encrypted_blob_data, cipher, hash_function)
json_appointment = appointment.to_json()
dict_appointment = json.loads(appointment.to_json())
assert (locator == json_appointment.get("locator") and start_time == json_appointment.get("start_time")
and end_time == json_appointment.get("end_time") and dispute_delta == json_appointment.get("dispute_delta")
and cipher == json_appointment.get("cipher") and hash_function == json_appointment.get("hash_function")
and encrypted_blob_data == json_appointment.get("encrypted_blob"))
assert (locator == dict_appointment.get("locator") and start_time == dict_appointment.get("start_time")
and end_time == dict_appointment.get("end_time") and dispute_delta == dict_appointment.get("dispute_delta")
and cipher == dict_appointment.get("cipher") and hash_function == dict_appointment.get("hash_function")
and encrypted_blob_data == dict_appointment.get("encrypted_blob"))

View File

@@ -6,6 +6,12 @@ from threading import Thread
from binascii import unhexlify
from queue import Queue, Empty
from cryptography.hazmat.backends import default_backend
from cryptography.hazmat.primitives import hashes
from cryptography.hazmat.primitives.serialization import load_pem_private_key
from cryptography.hazmat.primitives.asymmetric import ec
from cryptography.exceptions import InvalidSignature
from apps.cli.blob import Blob
from pisa.watcher import Watcher
from pisa.responder import Responder
@@ -16,7 +22,7 @@ from test.simulator.utils import sha256d
from test.simulator.transaction import TX
from pisa.utils.auth_proxy import AuthServiceProxy
from test.unit.conftest import generate_block, generate_blocks
from pisa.conf import EXPIRY_DELTA, BTC_RPC_USER, BTC_RPC_PASSWD, BTC_RPC_HOST, BTC_RPC_PORT
from pisa.conf import EXPIRY_DELTA, BTC_RPC_USER, BTC_RPC_PASSWD, BTC_RPC_HOST, BTC_RPC_PORT, PISA_SECRET_KEY
logging.getLogger().disabled = True
@@ -24,6 +30,12 @@ APPOINTMENTS = 5
START_TIME_OFFSET = 1
END_TIME_OFFSET = 1
with open(PISA_SECRET_KEY, "r") as key_file:
pubkey_pem = key_file.read().encode("utf-8")
# TODO: should use the public key file instead, but it is not currently exported in the configuration
signing_key = load_pem_private_key(pubkey_pem, password=None, backend=default_backend())
public_key = signing_key.public_key()
@pytest.fixture(scope="module")
def watcher():
@@ -70,6 +82,16 @@ def create_appointments(n):
return appointments, locator_uuid_map, dispute_txs
def is_signature_valid(appointment, signature, pk):
# verify the signature
try:
data = appointment.to_json().encode('utf-8')
pk.verify(signature, data, ec.ECDSA(hashes.SHA256()))
except InvalidSignature:
return False
return True
def test_init(watcher):
assert type(watcher.appointments) is dict and len(watcher.appointments) == 0
assert type(watcher.locator_uuid_map) is dict and len(watcher.locator_uuid_map) == 0
@@ -88,9 +110,16 @@ def test_add_appointment(run_bitcoind, watcher):
# We should be able to add appointments up to the limit
for _ in range(10):
appointment, dispute_tx = generate_dummy_appointment()
added_appointment = watcher.add_appointment(appointment)
added_appointment, sig = watcher.add_appointment(appointment)
assert added_appointment is True
assert is_signature_valid(appointment, sig, public_key)
def test_sign_appointment(watcher):
appointment, _ = generate_dummy_appointment()
signature = watcher.sign_appointment(appointment)
assert is_signature_valid(appointment, signature, public_key)
def test_add_too_many_appointments(watcher):
@@ -99,14 +128,16 @@ def test_add_too_many_appointments(watcher):
for _ in range(MAX_APPOINTMENTS):
appointment, dispute_tx = generate_dummy_appointment()
added_appointment = watcher.add_appointment(appointment)
added_appointment, sig = watcher.add_appointment(appointment)
assert added_appointment is True
assert is_signature_valid(appointment, sig, public_key)
appointment, dispute_tx = generate_dummy_appointment()
added_appointment = watcher.add_appointment(appointment)
added_appointment, sig = watcher.add_appointment(appointment)
assert added_appointment is False
assert sig is None
def test_do_subscribe(watcher):