diff --git a/common/cryptographer.py b/common/cryptographer.py index ecef7cd..5095a8e 100644 --- a/common/cryptographer.py +++ b/common/cryptographer.py @@ -278,7 +278,7 @@ class Cryptographer: return None if not isinstance(sk, PrivateKey): - logger.error("The value passed as sk is not a private key (EllipticCurvePrivateKey)") + logger.error("The value passed as sk is not a private key (PrivateKey)") return None try: diff --git a/teos/db_manager.py b/common/db_manager.py similarity index 100% rename from teos/db_manager.py rename to common/db_manager.py diff --git a/teos/appointments_dbm.py b/teos/appointments_dbm.py index cc1fcd0..8c80e64 100644 --- a/teos/appointments_dbm.py +++ b/teos/appointments_dbm.py @@ -1,11 +1,10 @@ import json import plyvel -from teos.db_manager import DBManager - from teos import LOG_PREFIX from common.logger import Logger +from common.db_manager import DBManager logger = Logger(actor="AppointmentsDBM", log_name_prefix=LOG_PREFIX) diff --git a/teos/users_dbm.py b/teos/users_dbm.py index d24b421..ea3cb0e 100644 --- a/teos/users_dbm.py +++ b/teos/users_dbm.py @@ -2,9 +2,9 @@ import json import plyvel from teos import LOG_PREFIX -from teos.db_manager import DBManager from common.logger import Logger +from common.db_manager import DBManager from common.tools import is_compressed_pk logger = Logger(actor="UsersDBM", log_name_prefix=LOG_PREFIX) diff --git a/test/common/unit/conftest.py b/test/common/unit/conftest.py index 3752ac0..aa9b0ed 100644 --- a/test/common/unit/conftest.py +++ b/test/common/unit/conftest.py @@ -1,5 +1,8 @@ import pytest import random +from shutil import rmtree + +from common.db_manager import DBManager @pytest.fixture(scope="session", autouse=True) @@ -7,6 +10,17 @@ def prng_seed(): random.seed(0) +@pytest.fixture(scope="module") +def db_manager(): + manager = DBManager("test_db") + # Add last know block for the Responder in the db + + yield manager + + manager.db.close() + rmtree("test_db") + + def get_random_value_hex(nbytes): pseudo_random_value = random.getrandbits(8 * nbytes) prv_hex = "{:x}".format(pseudo_random_value) diff --git a/test/teos/unit/test_db_manager.py b/test/common/unit/test_db_manager.py similarity index 97% rename from test/teos/unit/test_db_manager.py rename to test/common/unit/test_db_manager.py index 2ee337d..bbcb030 100644 --- a/test/teos/unit/test_db_manager.py +++ b/test/common/unit/test_db_manager.py @@ -2,8 +2,8 @@ import os import shutil import pytest -from teos.db_manager import DBManager -from test.teos.unit.conftest import get_random_value_hex +from common.db_manager import DBManager +from test.common.unit.conftest import get_random_value_hex def open_create_db(db_path): diff --git a/watchtower-plugin/arg_parser.py b/watchtower-plugin/arg_parser.py new file mode 100644 index 0000000..3653f2f --- /dev/null +++ b/watchtower-plugin/arg_parser.py @@ -0,0 +1,74 @@ +from common.tools import is_compressed_pk, is_locator +from exceptions import InvalidParameter + + +def parse_register_arguments(args, default_port): + # Sanity checks + if len(args) == 0: + raise InvalidParameter("missing required parameter: tower_id") + + if len(args) > 3: + raise InvalidParameter("too many parameters: got {}, expected 3".format(len(args))) + + tower_id = args[0] + + if not isinstance(tower_id, str): + raise InvalidParameter("tower id must be a compressed public key (33-byte hex value) " + str(args)) + + # tower_id is of the form tower_id@[ip][:][port] + if "@" in tower_id: + if len(args) == 1: + tower_id, tower_endpoint = tower_id.split("@") + + if not tower_endpoint: + raise InvalidParameter("no tower endpoint was provided") + + # Only host was specified + if ":" not in tower_endpoint: + tower_endpoint = "{}:{}".format(tower_endpoint, default_port) + + # Colons where specified but no port, defaulting + elif tower_endpoint.endswith(":"): + tower_endpoint = "{}{}".format(tower_endpoint, default_port) + + else: + raise InvalidParameter("cannot specify host as both xxx@yyy and separate arguments") + + # host was specified, but no port, defaulting + elif len(args) == 2: + tower_endpoint = "{}:{}".format(args[1], default_port) + + # host and port specified + elif len(args) == 3: + tower_endpoint = "{}:{}".format(args[1], args[2]) + + else: + raise InvalidParameter("tower host is missing") + + if not is_compressed_pk(tower_id): + raise InvalidParameter("tower id must be a compressed public key (33-byte hex value)") + + return tower_id, tower_endpoint + + +def parse_get_appointment_arguments(args): + # Sanity checks + if len(args) == 0: + raise InvalidParameter("missing required parameter: tower_id") + + if len(args) == 1: + raise InvalidParameter("missing required parameter: locator") + + if len(args) > 2: + raise InvalidParameter("too many parameters: got {}, expected 2".format(len(args))) + + tower_id = args[0] + locator = args[1] + + if not is_compressed_pk(tower_id): + raise InvalidParameter("tower id must be a compressed public key (33-byte hex value)") + + if not is_locator(locator): + raise InvalidParameter("The provided locator is not valid", locator=locator) + + return tower_id, locator diff --git a/watchtower-plugin/exceptions.py b/watchtower-plugin/exceptions.py new file mode 100644 index 0000000..48cca04 --- /dev/null +++ b/watchtower-plugin/exceptions.py @@ -0,0 +1,37 @@ +class BasicException(Exception): + def __init__(self, msg, **kwargs): + self.msg = msg + self.kwargs = kwargs + + def __str__(self): + if len(self.kwargs) > 2: + params = "".join("{}={}, ".format(k, v) for k, v in self.kwargs.items()) + + # Remove the extra 2 characters (space and comma) and add all data to the final message. + message = self.msg + " ({})".format(params[:-2]) + + else: + message = self.msg + + return message + + def to_json(self): + response = {"error": self.msg} + response.update(self.kwargs) + return response + + +class InvalidParameter(BasicException): + """Raised when a command line parameter is invalid (either missing or wrong)""" + + +class InvalidKey(BasicException): + """Raised when there is an error loading the keys""" + + +class TowerConnectionError(BasicException): + """Raised when the tower responds with an error""" + + +class TowerResponseError(BasicException): + """Raised when the tower responds with an error""" diff --git a/watchtower-plugin/keys.py b/watchtower-plugin/keys.py new file mode 100644 index 0000000..92d9ace --- /dev/null +++ b/watchtower-plugin/keys.py @@ -0,0 +1,94 @@ +import os.path +from pathlib import Path +from binascii import hexlify +from cryptography.hazmat.backends import default_backend +from cryptography.hazmat.primitives import serialization +from cryptography.hazmat.primitives.asymmetric import ec + +from exceptions import InvalidKey +from common.cryptographer import Cryptographer + + +def save_key(sk, filename): + """ + Saves secret key on disk. + + Args: + sk (:obj:`PrivateKey`): a private key file to be saved on disk. + filename (:obj:`str`): the name that will be given to the key file. + """ + + der = sk.private_bytes( + encoding=serialization.Encoding.DER, + format=serialization.PrivateFormat.PKCS8, + encryption_algorithm=serialization.NoEncryption(), + ) + + with open(filename, "wb") as der_out: + der_out.write(der) + + +def generate_keys(data_dir): + """ + Generates a key pair for the client. + + Args: + data_dir (:obj:`str`): path to data directory where the keys will be stored. + + Returns: + :obj:`tuple`: a tuple containing a ``PrivateKey`` and a ``str`` representing the client sk and compressed + pk respectively. + + Raises: + :obj:`FileExistsError`: if the key pair already exists in the given directory. + """ + + # Create the output folder it it does not exist (and all the parents if they don't either) + Path(data_dir).mkdir(parents=True, exist_ok=True) + sk_file_name = os.path.join(data_dir, "cli_sk.der") + + if os.path.exists(sk_file_name): + raise FileExistsError("The client key pair already exists") + + sk = ec.generate_private_key(ec.SECP256K1, default_backend()) + save_key(sk, sk_file_name) + + compressed_pk = sk.public_key().public_bytes( + encoding=serialization.Encoding.X962, format=serialization.PublicFormat.CompressedPoint + ) + + return sk, hexlify(compressed_pk).decode("utf-8") + + +def load_keys(data_dir): + """ + Loads a the client key pair. + + Args: + data_dir (:obj:`str`): path to data directory where the keys are stored. + + Returns: + :obj:`tuple`: a tuple containing a ``PrivateKey`` and a ``str`` representing the client sk and compressed + pk respectively. + + Raises: + :obj:`InvalidKey `: if any of the keys is invalid or cannot be loaded. + """ + + if not isinstance(data_dir, str): + raise ValueError("Invalid data_dir. Please check your settings") + + sk_file_path = os.path.join(data_dir, "cli_sk.der") + + cli_sk_der = Cryptographer.load_key_file(sk_file_path) + cli_sk = Cryptographer.load_private_key_der(cli_sk_der) + + if cli_sk is None: + raise InvalidKey("Client private key is invalid or cannot be parsed") + + compressed_cli_pk = Cryptographer.get_compressed_pk(cli_sk.public_key) + + if compressed_cli_pk is None: + raise InvalidKey("Client public key cannot be loaded") + + return cli_sk, compressed_cli_pk diff --git a/watchtower-plugin/net/http.py b/watchtower-plugin/net/http.py new file mode 100644 index 0000000..4b88a5e --- /dev/null +++ b/watchtower-plugin/net/http.py @@ -0,0 +1,69 @@ +import json +import requests +from requests import ConnectionError, ConnectTimeout +from requests.exceptions import MissingSchema, InvalidSchema, InvalidURL + +from common import constants +from exceptions import TowerConnectionError, TowerResponseError + + +def post_request(data, endpoint): + """ + Sends a post request to the tower. + + Args: + data (:obj:`dict`): a dictionary containing the data to be posted. + endpoint (:obj:`str`): the endpoint to send the post request. + + Returns: + :obj:`dict`: a json-encoded dictionary with the server response if the data can be posted. + + Raises: + :obj:`ConnectionError`: if the client cannot connect to the tower. + """ + + try: + return requests.post(url=endpoint, json=data, timeout=5) + + except ConnectTimeout: + message = "Can't connect to the Eye of Satoshi at {}. Connection timeout".format(endpoint) + + except ConnectionError: + message = "Can't connect to the Eye of Satoshi at {}. Tower cannot be reached".format(endpoint) + + except (InvalidSchema, MissingSchema, InvalidURL): + message = "Invalid URL. No schema, or invalid schema, found ({})".format(endpoint) + + raise TowerConnectionError(message) + + +def process_post_response(response): + """ + Processes the server response to a post request. + + Args: + response (:obj:`requests.models.Response`): a ``Response`` object obtained from the request. + + Returns: + :obj:`dict`: a dictionary containing the tower's response data if the response type is + ``HTTP_OK``. + + Raises: + :obj:`TowerResponseError `: if the tower responded with an error, or the + response was invalid. + """ + + try: + response_json = response.json() + + except (json.JSONDecodeError, AttributeError): + raise TowerResponseError( + "The server returned a non-JSON response", status_code=response.status_code, reason=response.reason + ) + + if response.status_code not in [constants.HTTP_OK, constants.HTTP_NOT_FOUND]: + raise TowerResponseError( + "The server returned an error", status_code=response.status_code, reason=response.reason, data=response_json + ) + + return response_json diff --git a/watchtower-plugin/requirements.txt b/watchtower-plugin/requirements.txt new file mode 100644 index 0000000..dc58719 --- /dev/null +++ b/watchtower-plugin/requirements.txt @@ -0,0 +1,6 @@ +pyln-client +requests +coincurve +cryptography +pyzbase32 +plyvel \ No newline at end of file diff --git a/watchtower-plugin/template.conf b/watchtower-plugin/template.conf new file mode 100644 index 0000000..18d0403 --- /dev/null +++ b/watchtower-plugin/template.conf @@ -0,0 +1,3 @@ +[teos] +api_port = 9814 + diff --git a/watchtower-plugin/tower_info.py b/watchtower-plugin/tower_info.py new file mode 100644 index 0000000..4b2034b --- /dev/null +++ b/watchtower-plugin/tower_info.py @@ -0,0 +1,19 @@ +class TowerInfo: + def __init__(self, endpoint, available_slots): + self.endpoint = endpoint + self.available_slots = available_slots + + @classmethod + def from_dict(cls, tower_data): + endpoint = tower_data.get("endpoint") + available_slots = tower_data.get("available_slots") + + if any(v is None for v in [endpoint, available_slots]): + raise ValueError("Wrong appointment data, some fields are missing") + if available_slots is None: + raise ValueError("Wrong tower data, some fields are missing") + + return cls(endpoint, available_slots) + + def to_dict(self): + return self.__dict__ diff --git a/watchtower-plugin/towers_dbm.py b/watchtower-plugin/towers_dbm.py new file mode 100644 index 0000000..ba54a18 --- /dev/null +++ b/watchtower-plugin/towers_dbm.py @@ -0,0 +1,119 @@ +import json + +from common.db_manager import DBManager +from common.tools import is_compressed_pk + + +class TowersDBM(DBManager): + """ + The :class:`TowersDBM` is in charge of interacting with the towers database (``LevelDB``). + Keys and values are stored as bytes in the database but processed as strings by the manager. + + Args: + db_path (:obj:`str`): the path (relative or absolute) to the system folder containing the database. A fresh + database will be created if the specified path does not contain one. + + Raises: + :obj:`ValueError`: If the provided ``db_path`` is not a string. + :obj:`plyvel.Error`: If the db is currently unavailable (being used by another process). + """ + + def __init__(self, db_path, plugin): + if not isinstance(db_path, str): + raise ValueError("db_path must be a valid path/name") + + super().__init__(db_path) + self.plugin = plugin + + def store_tower_record(self, tower_id, tower_data): + """ + Stores a tower record to the database. ``tower_id`` is used as identifier. + + Args: + tower_id (:obj:`str`): a 33-byte hex-encoded string identifying the tower. + tower_data (:obj:`dict`): the tower associated data, as a dictionary. + + Returns: + :obj:`bool`: True if the tower record was stored in the database, False otherwise. + """ + + if is_compressed_pk(tower_id): + try: + self.create_entry(tower_id, json.dumps(tower_data.to_dict())) + self.plugin.log("Adding tower to Tower's db (id={})".format(tower_id)) + return True + + except (json.JSONDecodeError, TypeError): + self.plugin.log( + "Could't add tower to db. Wrong tower data format (tower_id={}, tower_data={})".format( + tower_id, tower_data + ) + ) + return False + + else: + self.plugin.log( + "Could't add user to db. Wrong pk format (tower_id={}, tower_data={})".format(tower_id, tower_data) + ) + return False + + def load_tower_record(self, tower_id): + """ + Loads a tower record from the database using the ``tower_id`` as identifier. + + Args: + + tower_id (:obj:`str`): a 33-byte hex-encoded string identifying the tower. + + Returns: + :obj:`dict`: A dictionary containing the tower data if the ``key`` is found. + + Returns ``None`` otherwise. + """ + + try: + data = self.load_entry(tower_id) + data = json.loads(data) + except (TypeError, json.decoder.JSONDecodeError): + data = None + + return data + + def delete_tower_record(self, tower_id): + """ + Deletes a tower record from the database. + + Args: + tower_id (:obj:`str`): a 33-byte hex-encoded string identifying the tower. + + Returns: + :obj:`bool`: True if the tower was deleted from the database or it was non-existent, False otherwise. + """ + + try: + self.delete_entry(tower_id) + self.plugin.log("Deleting tower from Tower's db (id={})".format(tower_id)) + return True + + except TypeError: + self.plugin.log("Cannot delete user from db, user key has wrong type (id={})".format(tower_id)) + return False + + def load_all_tower_records(self): + """ + Loads all tower records from the database. + + Returns: + :obj:`dict`: A dictionary containing all tower records indexed by ``tower_id``. + + Returns an empty dictionary if no data is found. + """ + + data = {} + + for k, v in self.db.iterator(): + # Get uuid and appointment_data from the db + tower_id = k.decode("utf-8") + data[tower_id] = json.loads(v) + + return data diff --git a/watchtower-plugin/watchtower.py b/watchtower-plugin/watchtower.py new file mode 100755 index 0000000..95f68f5 --- /dev/null +++ b/watchtower-plugin/watchtower.py @@ -0,0 +1,163 @@ +#!/usr/bin/env python3 +import os +import plyvel +from pyln.client import Plugin + +from common.config_loader import ConfigLoader +from common.cryptographer import Cryptographer + +import arg_parser +from tower_info import TowerInfo +from towers_dbm import TowersDBM +from keys import generate_keys, load_keys +from net.http import post_request, process_post_response +from exceptions import TowerConnectionError, TowerResponseError, InvalidParameter + + +DATA_DIR = os.path.expanduser("~/.teos_cli/") +CONF_FILE_NAME = "teos_cli.conf" + +DEFAULT_CONF = { + "DEFAULT_PORT": {"value": 9814, "type": int}, + "APPOINTMENTS_FOLDER_NAME": {"value": "appointment_receipts", "type": str, "path": True}, + "TOWERS_DB": {"value": "towers", "type": str, "path": True}, + "CLI_PRIVATE_KEY": {"value": "cli_sk.der", "type": str, "path": True}, +} + + +plugin = Plugin() + + +class WTClient: + def __init__(self, sk, user_id, config): + self.sk = sk + self.user_id = user_id + self.db_manager = TowersDBM(config.get("TOWERS_DB"), plugin) + self.towers = {} + self.config = config + + # Populate the towers dict with data from the db + for tower_id, tower_info in self.db_manager.load_all_tower_records().items(): + self.towers[tower_id] = TowerInfo.from_dict(tower_info) + + +@plugin.init() +def init(options, configuration, plugin): + try: + plugin.log("Generating a new key pair for the watchtower client. Keys stored at {}".format(DATA_DIR)) + cli_sk, compressed_cli_pk = generate_keys(DATA_DIR) + + except FileExistsError: + plugin.log("A key file for the watchtower client already exists. Loading it") + cli_sk, compressed_cli_pk = load_keys(DATA_DIR) + + plugin.log("Plugin watchtower client initialized") + config_loader = ConfigLoader(DATA_DIR, CONF_FILE_NAME, DEFAULT_CONF, {}) + + try: + plugin.wt_client = WTClient(cli_sk, compressed_cli_pk, config_loader.build_config()) + except plyvel.IOError: + plugin.log("Cannot load towers db. Resource temporarily unavailable") + # TODO: Check how to make the plugin stop + + +@plugin.method("register", desc="Register your public key with the tower") +def register(plugin, *args): + """ + Registers the user to the tower. + + Args: + plugin (:obj:`Plugin`): this plugin. + args (:obj:`list`): a list of arguments. Must contain the tower_id and endpoint. + + Accepted input formats: + - tower_id@host:port + - tower_id@host (will default port to DEFAULT_PORT) + - tower_id host port + - tower_id host (will default port to DEFAULT_PORT) + + Returns: + :obj:`dict`: a dictionary containing the subscription data. + """ + + try: + tower_id, tower_endpoint = arg_parser.parse_register_arguments( + args, plugin.wt_client.config.get("DEFAULT_PORT") + ) + + # Defaulting to http hosts for now + if not tower_endpoint.startswith("http"): + tower_endpoint = "http://" + tower_endpoint + + # Send request to the server. + register_endpoint = "{}/register".format(tower_endpoint) + data = {"public_key": plugin.wt_client.user_id} + + plugin.log("Registering in the Eye of Satoshi") + + response = process_post_response(post_request(data, register_endpoint)) + plugin.log("Registration succeeded. Available slots: {}".format(response.get("available_slots"))) + + # Save data + tower_info = TowerInfo(tower_endpoint, response.get("available_slots")) + plugin.wt_client.towers[tower_id] = tower_info + plugin.wt_client.db_manager.store_tower_record(tower_id, tower_info) + + return response + + except (InvalidParameter, TowerConnectionError, TowerResponseError) as e: + plugin.log(str(e), level="error") + return e.to_json() + + +@plugin.method("getappointment", desc="Gets appointment data from the tower given a locator") +def get_appointment(plugin, *args): + """ + Gets information about an appointment from the tower. + + Args: + plugin (:obj:`Plugin`): this plugin. + args (:obj:`list`): a list of arguments. Must contain a single argument, the locator. + + Returns: + :obj:`dict`: a dictionary containing the appointment data. + """ + + # FIXME: All responses from the tower should be signed. Not using teos_pk atm. + + try: + tower_id, locator = arg_parser.parse_get_appointment_arguments(args) + + if tower_id not in plugin.wt_client.towers: + raise InvalidParameter("tower_id is not within the registered towers", tower_id=tower_id) + + message = "get appointment {}".format(locator) + signature = Cryptographer.sign(message.encode(), plugin.wt_client.sk) + data = {"locator": locator, "signature": signature} + + # Send request to the server. + get_appointment_endpoint = "{}/get_appointment".format(plugin.wt_client.towers[tower_id].endpoint) + plugin.log("Requesting appointment from the Eye of Satoshi at {}".format(get_appointment_endpoint)) + + response = process_post_response(post_request(data, get_appointment_endpoint)) + return response + + except (InvalidParameter, TowerConnectionError, TowerResponseError) as e: + plugin.log(str(e), level="error") + return e.to_json() + + +@plugin.hook("commitment_revocation") +def add_appointment(plugin, **kwargs): + commitment_txid = kwargs.get("commitment_txid") + penalty_tx = kwargs.get("penalty_tx") + plugin.log("commitment_txid {}, penalty_tx: {}".format(commitment_txid, penalty_tx)) + return {"result": "continue"} + + +@plugin.method("listtowers") +def list_towers(plugin): + return {k: v.to_dict() for k, v in plugin.wt_client.towers.items()} + + +plugin.run()