Skip to content
This repository has been archived by the owner on Sep 26, 2022. It is now read-only.

Add teos cli auth #262

Open
wants to merge 7 commits into
base: master
Choose a base branch
from
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
105 changes: 93 additions & 12 deletions common/cryptographer.py
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
import datetime
import os.path
import pyzbase32
from pathlib import Path
Expand All @@ -6,6 +7,12 @@
from coincurve import PrivateKey, PublicKey
from cryptography.exceptions import InvalidTag
from cryptography.hazmat.primitives.ciphers.aead import ChaCha20Poly1305
from cryptography.hazmat.primitives import hashes
from cryptography.hazmat.primitives import serialization
from cryptography.hazmat.primitives.asymmetric import rsa
from cryptography.hazmat.primitives.serialization import load_pem_private_key
from cryptography import x509
from cryptography.x509.oid import NameOID

from common.tools import is_256b_hex_str
from common.exceptions import InvalidKey, InvalidParameter, SignatureError, EncryptionError
Expand Down Expand Up @@ -192,44 +199,44 @@ def generate_key():
return PrivateKey()

@staticmethod
def save_key_file(key, name, data_dir):
def save_crypto_file(crypto_data, name, data_dir):
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I think we could call this save_bytes_file or save_file_bytes, given it has no actually cryptographic restriction.

"""
Saves a key to disk in DER format.
Saves cryptographic data, like a key or certificate, to disk in format.

Args:
key (:obj:`bytes`): the key to be saved to disk.
crypto_data (:obj:`bytes`): the key to be saved to disk.
name (:obj:`str`): the name of the key file to be generated.
data_dir (:obj:`str`): the data directory where the file will be saved.

Raises:
:obj:`InvalidParameter`: If the given key is not bytes or the name or data_dir are not strings.
:obj:`InvalidParameter`: If the given crypto data is not bytes or the name or data_dir are not strings.
"""

if not isinstance(key, bytes):
raise InvalidParameter("Key must be bytes, {} received".format(type(key)))
if not isinstance(crypto_data, bytes):
raise InvalidParameter("Crypto data must be bytes, {} received".format(type(crypto_data)))

if not isinstance(name, str):
raise InvalidParameter("Key name must be str, {} received".format(type(name)))
raise InvalidParameter("Crypto data name must be str, {} received".format(type(name)))

if not isinstance(data_dir, str):
raise InvalidParameter("Data dir must be str, {} received".format(type(data_dir)))

# 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)

with open(os.path.join(data_dir, "{}.der".format(name)), "wb") as der_out:
der_out.write(key)
with open(os.path.join(data_dir, "{}".format(name)), "wb") as crypto_out:
crypto_out.write(crypto_data)

@staticmethod
def load_key_file(file_path):
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The same applies to load_key_file, since currently the return messages make no sense for a certificate.

I think generalizing the messages and updating the name would do.

e.g.

"Key file path was expected, {} received" -> File path was expected, {} received"

"""
Loads a key from a key file.
Loads a key or certificate from a disk file.

Args:
file_path (:obj:`str`): the path to the key file to be loaded.
file_path (:obj:`str`): the path to the key or certificate file to be loaded.

Returns:
:obj:`bytes`: The key file data if the file can be found and read.
:obj:`bytes`: The key or certificate file data if the file can be found and read.

Raises:
:obj:`InvalidParameter`: if the file_path has wrong format or cannot be found.
Expand Down Expand Up @@ -375,3 +382,77 @@ def get_compressed_pk(pk):

except TypeError as e:
raise InvalidKey("PublicKey has invalid initializer", error=str(e))


@staticmethod
def generate_cert_key():
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I'm really debating between using openssl to generate the cert or do it with code. Given the code addition is rather small, I don't think it a big deal though.

However, I'd greatly encourage to use ECDSA instead of RSA. Furthermore, we are currently using DER for the rest of the keys, so I think we should also use DER for consistency here.

Copy link
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Got it, sounds good, I'll change to ECDSA. The reason I used PEM is because that's the format GRPC accepts. Sound okay for me to save the key/certificate as DER, and then just convert to PEM when we need to use them in GRPC?

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Yeah, I guess you can actually create Key and Cert objects with cryptography and the just call the pem / serialize_pem / whatever method gets the pem (I cannot recall atm) when passing the data to grpc.ssl_server_credentials.

If it ends up being too complicated it won't be worth it, so feel free to weight it and decide whatever you see fits best.

"""
Generates an RSA key with which to self-sign our TSL certificate and converts it to PEM format.

Returns:
:obj:`bytes`: An RSA key in PEM format.
"""
sk = rsa.generate_private_key(
public_exponent=65537,
key_size=2048,
)
sk_pem = sk.private_bytes(
encoding=serialization.Encoding.PEM,
format=serialization.PrivateFormat.TraditionalOpenSSL,
encryption_algorithm=serialization.NoEncryption(),
)

return sk_pem


@staticmethod
def generate_self_signed_cert(cert_key_path):
"""
Generates a self-signed TLS certificate for securing the connection between the CLI and the server.

Args:
cert_key_path(:obj:`str`): Path to RSA key.

Returns:
:obj:`Certificate`: A x509 certificate.

Raises:
:obj:`InvalidKey`: if the RSA key file is invalid or could not be found.

"""
try:
sk_pem = Cryptographer.load_key_file(cert_key_path)

except (InvalidParameter, InvalidKey):
raise InvalidKey("Failed to load RSA key needed for TLS certificate")


sk = load_pem_private_key(sk_pem, None)

# get public key from private key
pk = sk.public_key()

subject = issuer = x509.Name([
x509.NameAttribute(NameOID.ORGANIZATION_NAME, u"Teos watchtower"),
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The Eye of Satoshi maybe? I've never liked teos that much tbh, but that the acronym we've got.

x509.NameAttribute(NameOID.COMMON_NAME, u"localhost"),
])

cert = x509.CertificateBuilder().subject_name(
subject
).issuer_name(
issuer
).public_key(
pk
).serial_number(
x509.random_serial_number()
).not_valid_before(datetime.datetime.utcnow()).not_valid_after(
# Our certificate will be valid for 365 days
datetime.datetime.utcnow() + datetime.timedelta(days=365)
).add_extension(
x509.SubjectAlternativeName([x509.DNSName(u"localhost")]),
critical=False,
).sign(sk, hashes.SHA256())

cert = cert.public_bytes(serialization.Encoding.PEM)

return cert
6 changes: 3 additions & 3 deletions contrib/client/teos_client.py
Original file line number Diff line number Diff line change
Expand Up @@ -457,7 +457,7 @@ def main(command, args, command_line_conf):
else:
logger.info("Client id not found. Generating new keys")
user_sk = Cryptographer.generate_key()
Cryptographer.save_key_file(user_sk.to_der(), "user_sk", config.get("DATA_DIR"))
Cryptographer.save_crypto_file(user_sk.to_der(), "user_sk.der", config.get("DATA_DIR"))
user_id = Cryptographer.get_compressed_pk(user_sk.public_key)

if command == "register":
Expand All @@ -472,8 +472,8 @@ def main(command, args, command_line_conf):
logger.info("Registration succeeded. Available slots: {}".format(available_slots))
logger.info("Subscription expires at block {}".format(subscription_expiry))

teos_id_file = os.path.join(config.get("DATA_DIR"), "teos_pk")
Cryptographer.save_key_file(bytes.fromhex(teos_id), teos_id_file, config.get("DATA_DIR"))
teos_id_file = os.path.join(config.get("DATA_DIR"), "teos_pk.der")
Cryptographer.save_crypto_file(bytes.fromhex(teos_id), teos_id_file, config.get("DATA_DIR"))

if command == "add_appointment":
teos_id = load_teos_id(config.get("TEOS_PUBLIC_KEY"))
Expand Down
4 changes: 4 additions & 0 deletions teos/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,10 @@
"API_PORT": {"value": 9814, "type": int},
"RPC_BIND": {"value": "localhost", "type": str},
"RPC_PORT": {"value": 8814, "type": int},
"RPC_CERT": {"value": "teos_server.crt", "type": str, "path": True},
"RPC_CERT_KEY": {"value": "teos_cert.key", "type": str, "path": True},
"RPC_USER": {"value": "user", "type": str},
"RPC_PASS": {"value": "pass", "type": str},
"BTC_RPC_USER": {"value": "user", "type": str},
"BTC_RPC_PASSWORD": {"value": "passwd", "type": str},
"BTC_RPC_CONNECT": {"value": "127.0.0.1", "type": str},
Expand Down
33 changes: 31 additions & 2 deletions teos/cli/rpc_client.py
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@
from google.protobuf import json_format
from google.protobuf.empty_pb2 import Empty

from common.cryptographer import Cryptographer
from common.tools import is_compressed_pk, intify
from common.exceptions import InvalidParameter

Expand Down Expand Up @@ -46,15 +47,26 @@ class RPCClient:
Args:
rpc_host (:obj:`str`): the IP or host where the RPC server is hosted.
rpc_port (:obj:`int`): the port where the RPC server is hosted.
rpc_cert_path (:obj:`str`): path to certificate used to validate the server's TSL credentials.
rpc_user (:obj:`str`): a username that will be authenticated by the grpc server.
rpc_pass (:obj:`str`): a password that will be authenticated by the grpc server.


Attributes:
stub: The rpc client stub.
"""

def __init__(self, rpc_host, rpc_port):
def __init__(self, rpc_host, rpc_port, rpc_cert_path, rpc_user, rpc_pass):
self.rpc_host = rpc_host
self.rpc_port = rpc_port
channel = grpc.insecure_channel(f"{rpc_host}:{rpc_port}")

cert = Cryptographer.load_key_file(rpc_cert_path)
user_creds = UserPassCallCredentials(rpc_user, rpc_pass)
call_creds = grpc.metadata_call_credentials(user_creds)
ssl_creds = grpc.ssl_channel_credentials(root_certificates=cert)
creds = grpc.composite_channel_credentials(ssl_creds, call_creds)

channel = grpc.secure_channel(f"{rpc_host}:{rpc_port}", creds)
self.stub = TowerServicesStub(channel)

@formatted
Expand Down Expand Up @@ -95,3 +107,20 @@ def stop(self):
"""Stops TEOS gracefully."""
self.stub.stop(Empty())
print("Closing the Eye of Satoshi")


class UserPassCallCredentials(grpc.AuthMetadataPlugin):
"""
Creates call credentials, which include a username and password, to be passed to grpc.

Args:
username (:obj:`str`): a username that will be authenticated by the grpc server.
password (:obj:`str`): a password that will be authenticated by the grpc server.
"""
def __init__(self, username, password):
self._username = username
self._password = password

def __call__(self, context, callback):
metadata = [('user', self._username), ('pass', self._password)]
callback(metadata, None)
6 changes: 4 additions & 2 deletions teos/cli/teos_cli.py
Original file line number Diff line number Diff line change
Expand Up @@ -119,8 +119,10 @@ def __init__(self, data_dir, command_line_conf):

teos_rpc_host = config.get("RPC_BIND")
teos_rpc_port = config.get("RPC_PORT")

self.rpc_client = RPCClient(teos_rpc_host, teos_rpc_port)
rpc_cert = config.get("RPC_CERT")
rpc_user = config.get("RPC_USER")
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

rpc_user and rpc_password should be rewritable via command line params.

rpc_pass = config.get("RPC_PASS")
self.rpc_client = RPCClient(teos_rpc_host, teos_rpc_port, rpc_cert, rpc_user, rpc_pass)

@classmethod
def command(cls, command_cls):
Expand Down
53 changes: 46 additions & 7 deletions teos/rpc.py
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,38 @@
)


class AuthInterceptor(grpc.ServerInterceptor):
"""
The :obj:`AuthInterceptor` looks at every call made to the RPC server to see if the correct CLI credentials are provided.

Args:
rpc_user (:obj:`str`): the username supplied by the CLI when calling the RPC server.
rpc_pass (:obj:`str`): the password supplied by the CLI when calling the RPC server.

Attributes:
rpc_user (:obj:`str`): the username supplied by the CLI when calling the RPC server.
rpc_pass (:obj:`str`): the password supplied by the CLI when calling the RPC server.
"""
def __init__(self, rpc_user, rpc_pass):
self.rpc_user = rpc_user
self.rpc_pass = rpc_pass

def abort(ignored_request, context):
context.abort(grpc.StatusCode.UNAUTHENTICATED, 'Invalid user credentials')

self._abortion = grpc.unary_unary_rpc_method_handler(abort)

def intercept_service(self, continuation, handler_call_details):
metadata = dict(handler_call_details.invocation_metadata)
user = metadata.get("user")
password = metadata.get("pass")

if (user == self.rpc_user) and (password == self.rpc_pass):
return continuation(handler_call_details)
else:
return self._abortion


class RPC:
"""
The :obj:`RPC` is an external RPC server offered by tower to receive requests from the CLI.
Expand All @@ -29,12 +61,16 @@ class RPC:
rpc_server (:obj:`grpc.Server <grpc.Server>`): The non-started gRPC server instance.
"""

def __init__(self, rpc_bind, rpc_port, internal_api_endpoint):
def __init__(self, rpc_bind, rpc_port, internal_api_endpoint, teos_sk, certificate, rpc_user, rpc_pass):
self.logger = get_logger(component=RPC.__name__)
self.endpoint = f"{rpc_bind}:{rpc_port}"
self.rpc_server = grpc.server(futures.ThreadPoolExecutor(max_workers=10))
self.rpc_server.add_insecure_port(self.endpoint)
add_TowerServicesServicer_to_server(_RPC(internal_api_endpoint, self.logger), self.rpc_server)

an_interceptor = AuthInterceptor(rpc_user, rpc_pass)
self.rpc_server = grpc.server(futures.ThreadPoolExecutor(max_workers=10), interceptors=[an_interceptor])
server_credentials = grpc.ssl_server_credentials(((teos_sk, certificate,),))

self.rpc_server.add_secure_port(self.endpoint, server_credentials)
add_TowerServicesServicer_to_server(_RPC(internal_api_endpoint, self.logger, rpc_user, rpc_pass), self.rpc_server)

def teardown(self):
self.logger.info("Stopping")
Expand Down Expand Up @@ -72,9 +108,12 @@ class _RPC(TowerServicesServicer):
stub (:obj:`TowerServicesStub`): The rpc client stub.
"""

def __init__(self, internal_api_endpoint, logger):
def __init__(self, internal_api_endpoint, logger, rpc_user, rpc_pass):
self.logger = logger
self.internal_api_endpoint = internal_api_endpoint
self.rpc_user = rpc_user
self.rpc_pass = rpc_pass

channel = grpc.insecure_channel(self.internal_api_endpoint)
self.stub = TowerServicesStub(channel)

Expand All @@ -99,7 +138,7 @@ def stop(self, request, context):
return self.stub.stop(request)


def serve(rpc_bind, rpc_port, internal_api_endpoint, logging_port, stop_event):
def serve(rpc_bind, rpc_port, internal_api_endpoint, logging_port, stop_event, teos_key, rpc_cert, rpc_user, rpc_pass):
"""
Serves the external RPC API at the given endpoint and connects it to the internal api.

Expand All @@ -115,7 +154,7 @@ def serve(rpc_bind, rpc_port, internal_api_endpoint, logging_port, stop_event):
"""

setup_logging(logging_port)
rpc = RPC(rpc_bind, rpc_port, internal_api_endpoint)
rpc = RPC(rpc_bind, rpc_port, internal_api_endpoint, teos_key, rpc_cert, rpc_user, rpc_pass)
# Ignores SIGINT so the main process can handle the teardown
signal(SIGINT, ignore_signal)
rpc.rpc_server.start()
Expand Down
17 changes: 16 additions & 1 deletion teos/teosd.py
Original file line number Diff line number Diff line change
Expand Up @@ -142,6 +142,17 @@ def __init__(self, config, sk, logger, logging_port):
bitcoind_feed_params,
)

if not os.path.exists(self.config.get("RPC_CERT_KEY")):
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I'm guessing this is here for the first bootstrap, but I think there's an uncovered edge case.

If the sk does not exists and the cert does exists, you will generate a new sk and load the old cert. That will not work.

I think you need to check that they both exist at the same time. There are basically 4 cases here:

  • Cert + SK -> Check that the SK and the CERT match. If not, you need to generate a new pair**
  • Cert + No SK -> The CERT is useless, you need to generate a new pair**
  • No cert + SK -> You need to generate a CERT, but I'm pretty sure the CERT that the admin has won't be valid anymore, since the dates won't match (care to double check this?), so probably you need to generate a new pair**
  • No CERT nor SK -> You need to generate a new pair**

** For the new pair generation, I think the best approach is not doing it straight-away. This is the same case as for when the CERT expires. A new RPC command (only available from localhost) needs to be added to re-create the CERT.

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

So basically here we should return an error regarding the data being invalid / corrupted / not found (whatever sounds good) and ask the user to regenerate the CERT.

sk_pem = Cryptographer.generate_cert_key()
Cryptographer.save_crypto_file(sk_pem, "teos_cert.key", self.config.get("DATA_DIR"))

if not os.path.exists(self.config.get("RPC_CERT")):
cert = Cryptographer.generate_self_signed_cert(self.config.get("RPC_CERT_KEY"))
Cryptographer.save_crypto_file(cert, "teos_server.crt", self.config.get("DATA_DIR"))

sk_pem = Cryptographer.load_key_file(self.config.get("RPC_CERT_KEY"))
certificate = Cryptographer.load_key_file(self.config.get("RPC_CERT"))

# Set up the internal API
self.internal_api_endpoint = f'{self.config.get("INTERNAL_API_HOST")}:{self.config.get("INTERNAL_API_PORT")}'
self.internal_api = InternalAPI(
Expand All @@ -157,6 +168,10 @@ def __init__(self, config, sk, logger, logging_port):
self.internal_api_endpoint,
self.logging_port,
self.stop_event,
sk_pem,
certificate,
self.config.get("RPC_USER"),
self.config.get("RPC_PASS"),
),
daemon=True,
)
Expand Down Expand Up @@ -366,7 +381,7 @@ def main(config):
if not os.path.exists(config.get("TEOS_SECRET_KEY")) or config.get("OVERWRITE_KEY"):
logger.info("Generating a new key pair")
sk = Cryptographer.generate_key()
Cryptographer.save_key_file(sk.to_der(), "teos_sk", config.get("DATA_DIR"))
Cryptographer.save_crypto_file(sk.to_der(), "teos_sk.der", config.get("DATA_DIR"))

else:
logger.info("Tower identity found. Loading keys")
Expand Down
Loading