def test_get_appointment_in_responder(api, client, appointment): # Mock the appointment in the Responder tracker_data = { "locator": appointment.locator, "dispute_txid": get_random_value_hex(32), "penalty_txid": get_random_value_hex(32), "penalty_rawtx": get_random_value_hex(250), "user_id": get_random_value_hex(16), } tx_tracker = TransactionTracker.from_dict(tracker_data) uuid = hash_160("{}{}".format(appointment.locator, user_id)) api.watcher.db_manager.create_triggered_appointment_flag(uuid) api.watcher.responder.db_manager.store_responder_tracker(uuid, tx_tracker.to_dict()) # Request back the data message = "get appointment {}".format(appointment.locator) signature = Cryptographer.sign(message.encode("utf-8"), user_sk) data = {"locator": appointment.locator, "signature": signature} # Next we can request it r = client.post(get_appointment_endpoint, json=data) assert r.status_code == HTTP_OK # Check that the appointment is on the Responder assert r.json.get("status") == "dispute_responded" # Check the the sent appointment matches the received one assert tx_tracker.locator == r.json.get("locator") assert tx_tracker.dispute_txid == r.json.get("appointment").get("dispute_txid") assert tx_tracker.penalty_txid == r.json.get("appointment").get("penalty_txid") assert tx_tracker.penalty_rawtx == r.json.get("appointment").get("penalty_rawtx")
def test_get_appointment(watcher, generate_dummy_appointment, generate_dummy_tracker, monkeypatch): # Get appointment should return back data as long a the user does have the requested appointment locator = get_random_value_hex(32) signature = get_random_value_hex(71) uuid = hash_160("{}{}".format(locator, user_id)) # Mock the user being registered monkeypatch.setattr(watcher.gatekeeper, "authenticate_user", lambda x, y: user_id) monkeypatch.setattr(watcher.gatekeeper, "has_subscription_expired", lambda x: (False, 1)) # The appointment can either be in the Watcher of the Responder, mock the former case appointment = generate_dummy_appointment() monkeypatch.setitem(watcher.appointments, uuid, appointment) monkeypatch.setattr(watcher.db_manager, "load_watcher_appointment", lambda x: appointment.to_dict()) # Request and check appointment_data, status = watcher.get_appointment(locator, signature) assert appointment_data == appointment.to_dict() assert status == AppointmentStatus.BEING_WATCHED # Do the same for the appointment being in the Responder monkeypatch.delitem(watcher.appointments, uuid) tracker = generate_dummy_tracker() monkeypatch.setattr(watcher.responder, "has_tracker", lambda x: True) monkeypatch.setattr(watcher.db_manager, "load_responder_tracker", lambda x: tracker.to_dict()) # Request and check tracker_data, status = watcher.get_appointment(locator, signature) assert tracker_data == tracker.to_dict() assert status == AppointmentStatus.DISPUTE_RESPONDED
def test_get_appointment_in_responder(internal_api, client, generate_dummy_tracker, monkeypatch): tx_tracker = generate_dummy_tracker() # Mock the appointment in the Responder uuid = hash_160("{}{}".format(tx_tracker.locator, user_id)) internal_api.watcher.responder.trackers[uuid] = tx_tracker.get_summary() internal_api.watcher.responder.db_manager.store_responder_tracker( uuid, tx_tracker.to_dict()) # mock the gatekeeper (user won't be registered if the previous tests weren't ran) monkeypatch.setattr(internal_api.watcher.gatekeeper, "authenticate_user", mock_authenticate_user) # Request back the data message = "get appointment {}".format(tx_tracker.locator) signature = Cryptographer.sign(message.encode("utf-8"), user_sk) data = {"locator": tx_tracker.locator, "signature": signature} # Next we can request it r = client.post(get_appointment_endpoint, json=data) assert r.status_code == HTTP_OK # Check that the appointment is on the Responder assert r.json.get("status") == AppointmentStatus.DISPUTE_RESPONDED # Check the the sent appointment matches the received one assert tx_tracker.locator == r.json.get("locator") assert tx_tracker.dispute_txid == r.json.get("appointment").get( "dispute_txid") assert tx_tracker.penalty_txid == r.json.get("appointment").get( "penalty_txid") assert tx_tracker.penalty_rawtx == r.json.get("appointment").get( "penalty_rawtx")
def test_get_appointment_in_watcher(internal_api, client, appointment, monkeypatch): # Mock the appointment in the Watcher uuid = hash_160("{}{}".format(appointment.locator, user_id)) extended_appointment_summary = { "locator": appointment.locator, "user_id": user_id } internal_api.watcher.appointments[uuid] = extended_appointment_summary internal_api.watcher.db_manager.store_watcher_appointment( uuid, appointment.to_dict()) # mock the gatekeeper (user won't be registered if the previous tests weren't ran) monkeypatch.setattr(internal_api.watcher.gatekeeper, "authenticate_user", mock_authenticate_user) # Next we can request it message = "get appointment {}".format(appointment.locator) signature = Cryptographer.sign(message.encode("utf-8"), user_sk) data = {"locator": appointment.locator, "signature": signature} r = client.post(get_appointment_endpoint, json=data) assert r.status_code == HTTP_OK # Check that the appointment is on the Watcher assert r.json.get("status") == AppointmentStatus.BEING_WATCHED # Cast the extended appointment (used by the tower) to a regular appointment (used by the user) appointment = Appointment.from_dict(appointment.to_dict()) # Check the the sent appointment matches the received one assert r.json.get("locator") == appointment.locator assert appointment.to_dict() == r.json.get("appointment")
def add_appointment(client, appointment_data, user_id): r = client.post(add_appointment_endpoint, json=appointment_data) if r.status_code == HTTP_OK: locator = appointment_data.get("appointment").get("locator") uuid = hash_160("{}{}".format(locator, user_id)) appointments[uuid] = appointment_data["appointment"] return r
def test_add_appointment_already_triggered(internal_api, stub, generate_dummy_appointment): # If the appointment has already been trigger we should get ALREADY_EXISTS stub.register(RegisterRequest(user_id=user_id)) appointment, _ = generate_dummy_appointment() appointment_uuid = hash_160("{}{}".format(appointment.locator, user_id)) # Adding the uuid to the Responder trackers so the Watcher thinks it is in there. The data does not actually matters internal_api.watcher.responder.trackers[appointment_uuid] = {} appointment_signature = Cryptographer.sign(appointment.serialize(), user_sk) e = send_wrong_appointment(stub, appointment, appointment_signature) assert e.value.code() == grpc.StatusCode.ALREADY_EXISTS assert "The provided appointment has already been triggered" in e.value.details( )
def test_get_appointment_in_watcher(api, client, appointment): # Mock the appointment in the Watcher uuid = hash_160("{}{}".format(appointment.locator, user_id)) api.watcher.db_manager.store_watcher_appointment(uuid, appointment.to_dict()) # Next we can request it message = "get appointment {}".format(appointment.locator) signature = Cryptographer.sign(message.encode("utf-8"), user_sk) data = {"locator": appointment.locator, "signature": signature} r = client.post(get_appointment_endpoint, json=data) assert r.status_code == HTTP_OK # Check that the appointment is on the Watcher assert r.json.get("status") == "being_watched" # Check the the sent appointment matches the received one appointment_dict = appointment.to_dict() appointment_dict.pop("user_id") assert r.json.get("locator") == appointment.locator assert appointment.to_dict() == r.json.get("appointment")
def get_appointment(self, locator, user_signature): """ Gets information about an appointment. The appointment can either be in the watcher, the responder, or not found. Args: locator (:obj:`str`): a 16-byte hex-encoded value used by the tower to detect channel breaches. user_signature (:obj:`str`): the signature of the request by the user. Returns: :obj:`tuple`: A tuple containing the appointment data and the status, either ``AppointmentStatus.BEING_WATCHED`` or ``AppointmentStatus.DISPUTE_RESPONDED`` Raises: :obj:`AppointmentNotFound`: if the appointment is not found in the tower. :obj:`SubscriptionExpired`: If the user subscription has expired. """ message = "get appointment {}".format(locator).encode("utf-8") user_id = self.gatekeeper.authenticate_user(message, user_signature) has_expired, expiry = self.gatekeeper.has_subscription_expired(user_id) if has_expired: raise SubscriptionExpired( f"Your subscription expired at block {expiry}") uuid = hash_160("{}{}".format(locator, user_id)) with self.rw_lock.gen_rlock(): if uuid in self.appointments: appointment_data = self.db_manager.load_watcher_appointment( uuid) status = AppointmentStatus.BEING_WATCHED elif self.responder.has_tracker(uuid): appointment_data = self.db_manager.load_responder_tracker(uuid) status = AppointmentStatus.DISPUTE_RESPONDED else: raise AppointmentNotFound("Cannot find {}".format(locator)) return appointment_data, status
def add_appointment(self, appointment, user_signature): """ Adds a new appointment to the ``appointments`` dictionary if ``max_appointments`` has not been reached. ``add_appointment`` is the entry point of the :obj:`Watcher`. Upon receiving a new appointment it will start monitoring the blockchain (``do_watch``) until ``appointments`` is empty. Once a breach is seen on the blockchain, the :obj:`Watcher` will decrypt the corresponding ``encrypted_blob`` and pass the information to the :obj:`Responder <teos.responder.Responder>`. The tower may store multiple appointments with the same ``locator`` to avoid DoS attacks based on data rewriting. `locators`` should be derived from the ``dispute_txid``, but that task is performed by the user, and the tower has no way of verifying whether or not they have been properly derived. Therefore, appointments are identified by ``uuid`` and stored in ``appointments`` and ``locator_uuid_map``. Args: appointment (:obj:`Appointment <common.appointment.Appointment>`): the appointment to be added to the :obj:`Watcher`. user_signature (:obj:`str`): the user's appointment signature (hex-encoded). Returns: :obj:`dict`: The tower response as a dict, containing: ``locator``, ``signature``, ``available_slots`` and ``subscription_expiry``. Raises: :obj:`AppointmentLimitReached`: If the tower cannot hold more appointments (cap reached). :obj:`AuthenticationFailure`: If the user cannot be authenticated. :obj:`NotEnoughSlots`: If the user does not have enough available slots, so the appointment is rejected. :obj:`SubscriptionExpired`: If the user subscription has expired. """ with self.rw_lock.gen_wlock(): if len(self.appointments) >= self.max_appointments: message = "Maximum appointments reached, appointment rejected" self.logger.info(message, locator=appointment.locator) raise AppointmentLimitReached(message) user_id = self.gatekeeper.authenticate_user( appointment.serialize(), user_signature) has_subscription_expired, expiry = self.gatekeeper.has_subscription_expired( user_id) if has_subscription_expired: raise SubscriptionExpired( f"Your subscription expired at block {expiry}") start_block = self.block_processor.get_block( self.last_known_block).get("height") extended_appointment = ExtendedAppointment( appointment.locator, appointment.encrypted_blob, appointment.to_self_delay, user_id, user_signature, start_block, ) # The uuids are generated as the RIPEMD160(locator||user_pubkey). # If an appointment is requested by the user the uuid can be recomputed and queried straightaway (no maps). uuid = hash_160("{}{}".format(extended_appointment.locator, user_id)) # If this is a copy of an appointment we've already reacted to, the new appointment is rejected. if self.responder.has_tracker(uuid): message = "Appointment already in Responder" self.logger.info(message) raise AppointmentAlreadyTriggered(message) # Add the appointment to the Gatekeeper available_slots = self.gatekeeper.add_update_appointment( user_id, uuid, extended_appointment) # Appointments that were triggered in blocks held in the cache dispute_txid = self.locator_cache.get_txid( extended_appointment.locator) if dispute_txid: try: penalty_txid, penalty_rawtx = self.check_breach( uuid, extended_appointment, dispute_txid) receipt = self.responder.handle_breach( uuid, extended_appointment.locator, dispute_txid, penalty_txid, penalty_rawtx, user_id, self.last_known_block, ) # At this point the appointment is accepted but data is only kept if it goes through the Responder. # Otherwise it is dropped. if receipt.delivered: self.db_manager.store_watcher_appointment( uuid, extended_appointment.to_dict()) self.db_manager.create_append_locator_map( extended_appointment.locator, uuid) self.db_manager.create_triggered_appointment_flag(uuid) except (EncryptionError, InvalidTransactionFormat): # If data inside the encrypted blob is invalid, the appointment is accepted but the data is dropped. # (same as with data that bounces in the Responder). This reduces the appointment slot count so it # could be used to discourage user misbehaviour. pass # Regular appointments that have not been triggered (or, at least, not recently) else: self.appointments[uuid] = extended_appointment.get_summary() if extended_appointment.locator in self.locator_uuid_map: # If the uuid is already in the map it means this is an update. if uuid not in self.locator_uuid_map[ extended_appointment.locator]: self.locator_uuid_map[ extended_appointment.locator].append(uuid) else: # Otherwise two users have sent an appointment with the same locator, so we need to store both. self.locator_uuid_map[extended_appointment.locator] = [ uuid ] self.db_manager.store_watcher_appointment( uuid, extended_appointment.to_dict()) self.db_manager.create_append_locator_map( extended_appointment.locator, uuid) try: signature = Cryptographer.sign( receipts.create_appointment_receipt( user_signature, start_block), self.signing_key) except (InvalidParameter, SignatureError): # This should never happen since data is sanitized, just in case to avoid a crash self.logger.error("Data couldn't be signed", appointment=extended_appointment.to_dict()) signature = None self.logger.info("New appointment accepted", locator=extended_appointment.locator) return { "locator": extended_appointment.locator, "start_block": extended_appointment.start_block, "signature": signature, "available_slots": available_slots, "subscription_expiry": self.gatekeeper.registered_users[user_id].subscription_expiry, }
def get_appointment(self): """ Gives 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 requested appointment. Returns not found if the user does not have the requested appointment or the locator is invalid. A ``status`` flag is added to the data provided by either the :obj:`Watcher <teos.watcher.Watcher>` or the :obj:`Responder <teos.responder.Responder>` that signals the status of the appointment. - Appointments hold by the :obj:`Watcher <teos.watcher.Watcher>` are flagged as ``being_watched``. - Appointments hold by the :obj:`Responder <teos.responder.Responder>` are flagged as ``dispute_triggered``. - Unknown appointments are flagged as ``not_found``. """ # 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 get_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 get_appointment request", from_addr="{}".format(remote_addr), locator=locator) message = "get appointment {}".format(locator).encode() signature = request_data.get("signature") user_id = self.watcher.gatekeeper.authenticate_user( message, signature) triggered_appointments = self.watcher.db_manager.load_all_triggered_flags( ) uuid = hash_160("{}{}".format(locator, user_id)) # If the appointment has been triggered, it should be in the locator (default else just in case). if uuid in triggered_appointments: appointment_data = self.watcher.db_manager.load_responder_tracker( uuid) if appointment_data: rcode = HTTP_OK # Remove user_id field from appointment data since it is an internal field appointment_data.pop("user_id") response = { "locator": locator, "status": "dispute_responded", "appointment": appointment_data } else: rcode = HTTP_NOT_FOUND response = {"locator": locator, "status": "not_found"} # Otherwise it should be either in the watcher, or not in the system. else: appointment_data = self.watcher.db_manager.load_watcher_appointment( uuid) if appointment_data: rcode = HTTP_OK # Remove user_id field from appointment data since it is an internal field appointment_data.pop("user_id") response = { "locator": locator, "status": "being_watched", "appointment": appointment_data } else: rcode = HTTP_NOT_FOUND response = {"locator": locator, "status": "not_found"} except (InspectionFailed, AuthenticationFailure): rcode = HTTP_NOT_FOUND response = {"locator": locator, "status": "not_found"} return jsonify(response), rcode
def test_do_watch(watcher, generate_dummy_appointment_w_trigger, monkeypatch): # do_watch creates a thread in charge of watching for breaches. It also triggers data deletion when necessary, based # in the block height of the received blocks. # Test the following: # - Adding transactions to the Watcher and trigger them # - Check triggered appointments are removed from the Watcher # - Outdate appointments and check they are also removed # Mock the user being registered expiry = 100 user_info = UserInfo(MAX_APPOINTMENTS, expiry) monkeypatch.setattr(watcher.gatekeeper, "authenticate_user", lambda x, y: user_id) monkeypatch.setattr(watcher.gatekeeper, "has_subscription_expired", lambda x: (False, expiry)) monkeypatch.setattr(watcher.gatekeeper, "get_user_info", lambda x: user_info) # Mock the interactions with the Gatekeeper monkeypatch.setattr(watcher.gatekeeper, "get_outdated_appointments", lambda x: []) monkeypatch.setattr(watcher.responder, "handle_breach", mock_receipt_true) # Add the appointments to the tower. We add them instead of mocking to avoid having to mock all the data structures # plus database commitment_txids = [] triggered_valid = [] for i in range(APPOINTMENTS): appointment, commitment_txid = generate_dummy_appointment_w_trigger() watcher.add_appointment(appointment, appointment.user_signature) commitment_txids.append(commitment_txid) uuid = hash_160("{}{}".format(appointment.locator, user_id)) if i < 2: triggered_valid.append(uuid) # Start the watching thread do_watch_thread = Thread(target=watcher.do_watch, daemon=True) do_watch_thread.start() # Mock a new block with the two first triggers in it block_id = get_random_value_hex(32) block = {"tx": commitment_txids[:2], "height": 1} monkeypatch.setattr(watcher.block_processor, "get_block", lambda x, blocking: block) watcher.block_queue.put(block_id) time.sleep(0.2) # After generating a block, the appointment count should have been reduced by 2 (two breaches) assert len(watcher.appointments) == APPOINTMENTS - 2 # This two first should have gone to the Responder, we can check the trigger flags to validate assert set( watcher.db_manager.load_all_triggered_flags()) == set(triggered_valid) # Mock two more transactions being triggered, this time with invalid data monkeypatch.setattr(watcher.responder, "handle_breach", mock_receipt_false) block_id = get_random_value_hex(32) block = {"tx": commitment_txids[2:4], "height": 2} monkeypatch.setattr(watcher.block_processor, "get_block", lambda x, blocking: block) watcher.block_queue.put(block_id) time.sleep(0.2) # Two more appointments should be gone but none of them should have gone trough the Responder assert len(watcher.appointments) == APPOINTMENTS - 4 # Check the triggers are the same as before assert set( watcher.db_manager.load_all_triggered_flags()) == set(triggered_valid) # The rest of appointments will timeout after the subscription timesout monkeypatch.setattr(watcher.gatekeeper, "get_outdated_appointments", lambda x: list(watcher.appointments.keys())) mock_generate_blocks(1, {}, watcher.block_queue) assert len(watcher.appointments) == 0