diff --git a/cli/teos_cli.py b/cli/teos_cli.py index aee10c31..0b3b8099 100644 --- a/cli/teos_cli.py +++ b/cli/teos_cli.py @@ -200,6 +200,46 @@ def get_all_appointments(teos_url): return None +def delete_appointment(locator, cli_sk, teos_pk, teos_url): + """ + Deletes information about an appointment from the tower. + + Args: + locator (:obj:`str`): the locator used to identify the appointment. + cli_sk (:obj:`PrivateKey`): the client's private key. + teos_pk (:obj:`PublicKey`): the tower's public key. + teos_url (:obj:`str`): the teos base url. + + Returns: + :obj:`dict` or :obj:`None`: a dictionary containing the appointment data if the locator is valid and the tower + responds. ``None`` otherwise. + + Raises: + :obj:`InvalidParameter `: if ``locator`` is invalid. + :obj:`ConnectionError`: if the client cannot connect to the tower. + :obj:`TowerResponseError `: if the tower responded with an error, or the + response was invalid. + """ + + # FIXME: All responses from the tower should be signed. Not using teos_pk atm. + + if not is_locator(locator): + raise InvalidParameter("The provided locator is not valid", locator=locator) + + message = "delete appointment {}".format(locator) + signature = Cryptographer.sign(message.encode(), cli_sk) + data = {"locator": locator, "signature": signature} + + # Send request to the server. + delete_appointment_endpoint = "{}/delete_appointment".format(teos_url) + logger.info("Sending appointment deletion request to the Eye of Satoshi") + server_response = post_request(data, delete_appointment_endpoint) + + response_json = process_post_response(server_response) + + return response_json + + def load_keys(teos_pk_path, user_sk_path): """ Loads all the keys required so sign, send, and verify the appointment. diff --git a/teos/api.py b/teos/api.py index b1c90ecc..970e0187 100644 --- a/teos/api.py +++ b/teos/api.py @@ -5,8 +5,8 @@ from teos import LOG_PREFIX import common.errors as errors from teos.inspector import InspectionFailed -from teos.watcher import AppointmentLimitReached, AppointmentAlreadyTriggered from teos.gatekeeper import NotEnoughSlots, AuthenticationFailure +from teos.watcher import AppointmentLimitReached, AppointmentAlreadyTriggered, AppointmentNotFound from common.logger import Logger from common.cryptographer import hash_160 @@ -87,6 +87,7 @@ def __init__(self, host, port, inspector, watcher): "/add_appointment": (self.add_appointment, ["POST"]), "/get_appointment": (self.get_appointment, ["POST"]), "/get_all_appointments": (self.get_all_appointments, ["GET"]), + "/delete_appointment": (self.delete_appointment, ["POST"]), } for url, params in routes.items(): @@ -300,6 +301,78 @@ def get_all_appointments(self): return response + def delete_appointment(self): + """ + Delete information about a given appointment state in the Watchtower. + + The information is requested by ``locator``. + + Returns: + :obj:`str`: A json formatted dictionary containing information about the appointment deletion request. + + Returns not found if the user does not have the requested appointment or the locator is invalid. + Returns bad request if the appointment does not exist in the Watchtower. + + A ``status`` flag is added to the data that signals the status of the deletion request. + + - A successfully deleted appointment is flagged as ``deletion_accepted``. + - An appointment that did not exist (or was already deleted), or where the locator is invalid or the user + does not have the requested appointment, are flagged as ``deletion_rejected``. + + :obj:`tuple`: A tuple containing the response (:obj:`str`) and response code (:obj:`int`). For accepted + appointments, the ``rcode`` is always 200 and the response contains the receipt signature (json). For + rejected appointments, the ``rcode`` is either 400 or 404: + + If the appointment is not found: 404 + If the request is invalid: 400 + If the appointment is already in the responder: 400 + message + """ + + # Getting the real IP if the server is behind a reverse proxy + remote_addr = get_remote_addr() + + # Check that data type and content are correct. Abort otherwise. + try: + request_data = get_request_data_json(request) + + except InvalidParameter as e: + logger.info("Received invalid delete_appointment request", from_addr="{}".format(remote_addr)) + return jsonify({"error": str(e), "error_code": errors.INVALID_REQUEST_FORMAT}), HTTP_BAD_REQUEST + + locator = request_data.get("locator") + + try: + self.inspector.check_locator(locator) + logger.info("Received delete_appointment request", from_addr=remote_addr, locator=locator) + + message = "delete appointment {}".format(locator).encode() + user_signature = request_data.get("signature") + user_id = self.watcher.gatekeeper.authenticate_user(message, user_signature) + + tower_signature = self.watcher.pop_appointment(locator, user_id, user_signature) + + rcode = HTTP_OK + response = { + "locator": locator, + "signature": tower_signature, + "available_slots": self.watcher.gatekeeper.registered_users[user_id].get("available_slots"), + "status": "deletion_accepted", + } + + except AppointmentNotFound: + rcode = HTTP_NOT_FOUND + response = {"locator": locator, "status": "deletion_rejected"} + + except AppointmentAlreadyTriggered as e: + rcode = HTTP_BAD_REQUEST + response = {"locator": locator, "status": "deletion_rejected", "error": e.msg} + + except (InspectionFailed, AuthenticationFailure): + rcode = HTTP_BAD_REQUEST + response = {"locator": locator, "status": "deletion_rejected"} + + return jsonify(response), rcode + def start(self): """ This function starts the Flask server used to run the API """ diff --git a/teos/watcher.py b/teos/watcher.py index 644bc79c..9355face 100644 --- a/teos/watcher.py +++ b/teos/watcher.py @@ -1,5 +1,5 @@ from queue import Queue -from threading import Thread +from threading import Thread, Lock from collections import OrderedDict from readerwriterlock import rwlock @@ -22,6 +22,10 @@ class AppointmentLimitReached(BasicException): """Raised when the tower maximum appointment count has been reached""" +class AppointmentNotFound(BasicException): + """Raised when an appointment is not found in the tower""" + + class AppointmentAlreadyTriggered(BasicException): """Raised when an appointment is sent to the Watcher but that same data has already been sent to the Responder""" @@ -335,6 +339,51 @@ def add_appointment(self, appointment, signature): "subscription_expiry": self.gatekeeper.registered_users[user_id].subscription_expiry, } + def delete_appointment(self, locator, user_id, user_signature): + """ + Deletes an appointment from the ``Watcher``. The tower will stop monitoring the deleted appointment. + + Args: + locator (:obj:`str`): a 16-byte hex string identifying the appointment. + user_id (:obj:`str`): the public key that identifies the user who request the deletion (33-bytes hex str). + user_signature (:obj:`str`): the signature of the request provided by the user. The tower will sign the + signature deletion is accepted. + + Returns: + :obj:`str`: the signature of the user's signature if the appointment if the deletion is accepted. + + Rises: + :obj:`AppointmentAlreadyTriggered`: if the appointment is already in the Responder. The deletion is + therefore rejected. + :obj:`AppointmentNotFound`: if the appointment cannot be found in the tower. The deletion is therefore + rejected. + """ + + uuid = hash_160("{}{}".format(locator, user_id)) + + # FIXME: We need to keep track of deletions + + if uuid in self.appointments: + # Delete the appointment from both the Watcher and the Gatekeeper + Cleaner.delete_completed_appointments([uuid], self.appointments, self.locator_uuid_map, self.db_manager) + Cleaner.delete_gatekeeper_appointments(self.gatekeeper, {uuid: user_id}) + + # Sign over the user signature as acceptance of the deletion request. + signature = Cryptographer.sign(user_signature.encode(), self.signing_key) + logger.info("Appointment deleted", locator=locator) + + return signature + + elif uuid in self.responder.trackers: + message = "Cannot delete an already triggered appointment" + logger.info(message, locator=locator) + raise AppointmentAlreadyTriggered(message) + + else: + message = "Appointment not found. Deletion rejected" + logger.info(message, locator=locator) + raise AppointmentNotFound(message) + def do_watch(self): """ Monitors the blockchain for channel breaches.