Module e2eeftp.server.server

E2EEFTP server implementation.

This module provides the server-side components for the End-to-End Encrypted FTP protocol. It includes a multi-threaded TCP server that handles secure file transfers with authentication and encryption.

The server uses Elliptic Curve Diffie-Hellman for key exchange and Ed25519 for identity verification, ensuring secure, authenticated connections. Each client connection is handled in a separate thread, allowing concurrent operations.

Key components: - E2EEFTPRequestHandler: Handles individual client connections and command processing - e2eeftp: The main server class extending ThreadingTCPServer - Command handlers for SEND, GET, LIST, DELETE, and HLIST operations

Functions

def main()
Expand source code
def main():
    """
    Main entry point for running the E2EEFTP server.

    This function creates an instance of the e2eeftp server and starts it,
    beginning the listening loop for incoming client connections. The server
    will run until interrupted (e.g., via KeyboardInterrupt).
    """
    server = e2eeftp()
    server.run()

Main entry point for running the E2EEFTP server.

This function creates an instance of the e2eeftp server and starts it, beginning the listening loop for incoming client connections. The server will run until interrupted (e.g., via KeyboardInterrupt).

Classes

class E2EEFTPRequestHandler (request, client_address, server)
Expand source code
class E2EEFTPRequestHandler(socketserver.BaseRequestHandler):
    """
    Request handler for each client connection, instantiated by the socketserver.

    This class manages the entire lifecycle of a client connection. It is
    responsible for performing the secure handshake, parsing client commands,
    and dispatching to the appropriate handler methods (e.g., for sending or
    receiving files).

    The command dispatch approach is intentionally flexible: subclasses can
    override `command_handlers` to add or modify supported commands without
    changing _handle_request logic.
    """

    command_handlers: dict[str, str] = {
        "SEND": "_receive_file",
        "GET": "_send_file",
        "LIST": "_send_list",
        "DELETE": "_delete_file",
        "HLIST": "_hlist"
    }

    req: dict[str, Comm] = {}

    def setup(self) -> None:
        """
        Called before handle() to perform any initialization actions.
        Runs exactly once per connection/instance.
        """
        self.update_command_handlers()
        super().setup()

    def handle(self):
        """
        The main entry point for handling a new client connection.

        This method orchestrates the session:
        1.  Performs the E2EE handshake to establish a secure channel.
        2.  Waits for and parses a command from the client.
        3.  Calls the internal method corresponding to the command.
        4.  Handles any exceptions during the session.
        5.  Ensures the connection is logged and closed cleanly.
        """
        address = self.client_address
        log.info(f"Accepted connection from {address}")
        try:
            server_key_path = "server_id.key"
            auth_keys_path = "authorized_clients.pub"

            log.info("Loading server identity and authorized keys...")
            
            if not os.path.exists(server_key_path):
                log.error(f"Server identity key '{server_key_path}' not found. Cannot secure connection.")
                return

            with open(server_key_path, "rb") as f:
                server_id_priv_key = serialization.load_pem_private_key(f.read(), password=None)
            
            authorized_client_keys = []
            if os.path.exists(auth_keys_path):
                with open(auth_keys_path, "r") as f:
                    for line in f:
                        line = line.strip()
                        if line and not line.startswith('#'):
                            try:
                                key_bytes = base64.b64decode(line)
                                authorized_client_keys.append(ed25519.Ed25519PublicKey.from_public_bytes(key_bytes))
                            except Exception as e:
                                log.warning(f"Skipping invalid key in {auth_keys_path}: {e}")
            else:
                log.warning(f"'{auth_keys_path}' not found. No clients will be authorized.")
                log.warning("Run 'generate_keys.py' to create client keys and the authorization file.")

            e2ee = E2EE()
            log.info(f"Performing secure handshake with {address}...")
            cipher = e2ee.server_handshake(self.request, server_id_priv_key, authorized_client_keys)
            log.info(f"Secure handshake with {address} complete.")
            self._handle_request(cipher)
        except FileNotFoundError as e:
            log.error(f"Identity key file not found: {e}. Please generate keys and authorize clients.")
        except ConnectionError as e:
            log.error(f"Handshake failed with {address}: {e}")
        except Exception as e:
            log.error(f"Error during session with {address}: {e}")
        finally:
            log.info(f"Connection with {address} closed.")

    def _recv_until(self, delimiter: bytes) -> bytes: 
        """ 
        Receives data from the socket until a specific delimiter is found. 

        This is a helper method to read data from the stream-based TCP socket 
        in a message-oriented way. It reads one byte at a time until the 
        `delimiter` is encountered.

        Args:
            delimiter (bytes): The byte sequence that marks the end of a message.

        Returns:
            bytes: The data received from the socket, including the delimiter.
                   Returns an empty bytestring if the client disconnects before
                   sending any data.
        """
        data = b''
        while not data.endswith(delimiter):
            chunk = self.request.recv(1)
            if not chunk: break
            data += chunk
        return data

    def _handle_request(self, cipher: AESCipher) -> None:
        """
        Parses the client's command and dispatches to the correct handler.

        Uses a dispatch map so subclasses can modify/add commands by overriding
        `command_handlers`.
        """
        header_data = self._recv_until(b'\n')
        if not header_data:
            return

        try:
            parts = header_data.decode().strip().split("|")
            command = parts[0].upper()

            handler_name = self.command_handlers.get(command)
            if handler_name is None:
                self.request.sendall(b"400|Invalid Command\n")
                log.warning(f"Invalid command from {self.client_address}: {command}")
                return

            handler = getattr(self, handler_name, None)
            if not callable(handler):
                self.request.sendall(b"500|Server Misconfigured\n")
                log.error(f"Handler {handler_name} for command {command} not implemented")
                return

            handler(*self._arg_paser(parts, cipher)) 

        except (IndexError, ValueError) as e:
            log.error(f"Malformed request from {self.client_address}: {header_data.strip()!r} - {e}")
            self.request.sendall(b"400|Malformed request\n")

    def _arg_paser(self, request_parts: list[str], cipher: AESCipher) -> tuple:
        """_summary_

        Args:
            request_parts (list[str]): The list of parts from the client's command header in the form of a list.

        Returns:
            tuple: The arguments to be passed to the command handler method, based on the command type. The mapping is as follows:
        """
        cmd_args: list = []
        command = request_parts[0].upper()
        if command == "SEND":
            cmd_args = [request_parts[1], int(request_parts[2]), cipher]
        elif command == "GET":
            cmd_args = [request_parts[1], cipher]
        elif command == "LIST" or command == "HLIST":
            cmd_args = []
        elif command == "DELETE":
            cmd_args = [request_parts[1]]
        return tuple(cmd_args) 

    def _hlist(self) -> None:
        """
        Sends a text file of all available commands that the server supports to the client.
        
        The server responds with a header `200|<content_length>` followed by a newline-separated string of commands.
        **Protocol**:
        1.  Sends header: `b"200|<size>"`
        2.  Sends body: A string of commands.
        """
        self.req["HLIST"] = Hlist(commands=list(self.command_handlers.keys()), request=self.request, log=log, )
        self.req["HLIST"].__hlist__ = self._hlist.__name__
        self.req["HLIST"].run()

    def _send_list(self) -> None:
        """
        Sends a list of available files in the 'received' directory to the client.

        The server responds with a header `200|<content_length>` followed by a
        newline-separated string of filenames.

        **Protocol**:
        1.  Sends header: `b"200|<size>"`
        2.  Sends body: A string of filenames.
        """
        self.req["LIST"] = List(request=self.request, log=log)
        self.req["LIST"].set_hlist(self._send_list.__name__)
        self.req["LIST"].run()

    def _delete_file(self, filename: str) -> None:
        """
        Deletes a specified file from the server's 'received' directory.

        Args:
            filename (str): The name of the file to delete.

        **Responses**:
        - On success: `b"200|File deleted\\n"`
        - If file not found: `b"404|File not found\\n"`
        """
        self.req["DELETE"] = Delete(filename=filename, request=self.request, log=log)
        self.req["DELETE"].set_hlist(self._delete_file.__name__)
        self.req["DELETE"].run()

    def _receive_file(self, filename: str, filesize: int, cipher: AESCipher) -> None:
        """
        Receives, decrypts, and saves a file sent by the client.

        This method reads a specified number of bytes (`filesize`) from the socket,
        which contains the encrypted file data. It then attempts to decrypt this
        data using the session's cipher and saves it to the `received` directory.

        Args:
            filename (str): The name to save the file as.
            filesize (int): The exact size of the incoming encrypted data buffer.
            cipher (AESCipher): The cipher instance for this session.

        **Responses**:
        - On success: `b"226|Transfer Complete\\n"`
        - On decryption failure: `b"500|Decryption Failed\\n"`
        """
        self.req["SEND"] = Send(filename=filename, filesize=filesize, cipher=cipher, request=self.request, log=log)
        self.req["SEND"].set_hlist(self._receive_file.__name__)
        self.req["SEND"].run()

    def _send_file(self, filename: str, cipher: AESCipher) -> None:
        """
        Encrypts and sends a requested file to the client.

        If the file exists in the 'received' directory, it is read, encrypted
        with the session cipher, and sent over the socket.

        Args:
            filename (str): The name of the file to send.
            cipher (AESCipher): The cipher instance for this session.

        **Protocol & Responses**:
        - If file found:
            1. Sends header: `b"200|<encrypted_size>\\n"`
            2. Sends body: The encrypted file data.
        - If file not found: `b"404|File not found: {filename}\\n"`
        - On server-side error: `b"500|Server Read Error\\n"`
        """
        self.req["GET"] = Get(filename=filename, cipher=cipher, request=self.request, log=log)
        self.req["GET"].set_hlist(self._send_file.__name__)
        self.req["GET"].run()

    def update_command_handlers(self):
        """
        Update the command handlers dictionary with dynamic handlers from request objects.

        This method iterates through the req dictionary (containing command objects)
        and updates the command_handlers mapping to include the __hlist__ method
        names for each command. This allows for dynamic command handler registration
        based on the current request state.

        The updated handlers are logged at debug level for troubleshooting.
        """
        self.command_handlers.update({comm_name: comm.__hlist__ for comm_name, comm in self.req.items()})
        log.debug(self.command_handlers)

Request handler for each client connection, instantiated by the socketserver.

This class manages the entire lifecycle of a client connection. It is responsible for performing the secure handshake, parsing client commands, and dispatching to the appropriate handler methods (e.g., for sending or receiving files).

The command dispatch approach is intentionally flexible: subclasses can override command_handlers to add or modify supported commands without changing _handle_request logic.

Ancestors

  • socketserver.BaseRequestHandler

Class variables

var command_handlers : dict[str, str]

The type of the None singleton.

var req : dict[str, Comm]

The type of the None singleton.

Methods

def handle(self)
Expand source code
def handle(self):
    """
    The main entry point for handling a new client connection.

    This method orchestrates the session:
    1.  Performs the E2EE handshake to establish a secure channel.
    2.  Waits for and parses a command from the client.
    3.  Calls the internal method corresponding to the command.
    4.  Handles any exceptions during the session.
    5.  Ensures the connection is logged and closed cleanly.
    """
    address = self.client_address
    log.info(f"Accepted connection from {address}")
    try:
        server_key_path = "server_id.key"
        auth_keys_path = "authorized_clients.pub"

        log.info("Loading server identity and authorized keys...")
        
        if not os.path.exists(server_key_path):
            log.error(f"Server identity key '{server_key_path}' not found. Cannot secure connection.")
            return

        with open(server_key_path, "rb") as f:
            server_id_priv_key = serialization.load_pem_private_key(f.read(), password=None)
        
        authorized_client_keys = []
        if os.path.exists(auth_keys_path):
            with open(auth_keys_path, "r") as f:
                for line in f:
                    line = line.strip()
                    if line and not line.startswith('#'):
                        try:
                            key_bytes = base64.b64decode(line)
                            authorized_client_keys.append(ed25519.Ed25519PublicKey.from_public_bytes(key_bytes))
                        except Exception as e:
                            log.warning(f"Skipping invalid key in {auth_keys_path}: {e}")
        else:
            log.warning(f"'{auth_keys_path}' not found. No clients will be authorized.")
            log.warning("Run 'generate_keys.py' to create client keys and the authorization file.")

        e2ee = E2EE()
        log.info(f"Performing secure handshake with {address}...")
        cipher = e2ee.server_handshake(self.request, server_id_priv_key, authorized_client_keys)
        log.info(f"Secure handshake with {address} complete.")
        self._handle_request(cipher)
    except FileNotFoundError as e:
        log.error(f"Identity key file not found: {e}. Please generate keys and authorize clients.")
    except ConnectionError as e:
        log.error(f"Handshake failed with {address}: {e}")
    except Exception as e:
        log.error(f"Error during session with {address}: {e}")
    finally:
        log.info(f"Connection with {address} closed.")

The main entry point for handling a new client connection.

This method orchestrates the session: 1. Performs the E2EE handshake to establish a secure channel. 2. Waits for and parses a command from the client. 3. Calls the internal method corresponding to the command. 4. Handles any exceptions during the session. 5. Ensures the connection is logged and closed cleanly.

def setup(self) ‑> None
Expand source code
def setup(self) -> None:
    """
    Called before handle() to perform any initialization actions.
    Runs exactly once per connection/instance.
    """
    self.update_command_handlers()
    super().setup()

Called before handle() to perform any initialization actions. Runs exactly once per connection/instance.

def update_command_handlers(self)
Expand source code
def update_command_handlers(self):
    """
    Update the command handlers dictionary with dynamic handlers from request objects.

    This method iterates through the req dictionary (containing command objects)
    and updates the command_handlers mapping to include the __hlist__ method
    names for each command. This allows for dynamic command handler registration
    based on the current request state.

    The updated handlers are logged at debug level for troubleshooting.
    """
    self.command_handlers.update({comm_name: comm.__hlist__ for comm_name, comm in self.req.items()})
    log.debug(self.command_handlers)

Update the command handlers dictionary with dynamic handlers from request objects.

This method iterates through the req dictionary (containing command objects) and updates the command_handlers mapping to include the hlist method names for each command. This allows for dynamic command handler registration based on the current request state.

The updated handlers are logged at debug level for troubleshooting.

class e2eeftp (host: str = '127.0.0.1', port: int = 5001, logging: bool = True)
Expand source code
class e2eeftp(socketserver.ThreadingTCPServer):
    """
    A multi-threaded TCP server for secure file transfers.

    This server uses `socketserver.ThreadingTCPServer` to handle each incoming
    client connection in a separate thread. This allows for concurrent file
    transfer operations.

    Security is established on a per-connection basis using an Elliptic Curve
    Diffie-Hellman (ECDH) key exchange. This generates a unique, ephemeral
    session key for each client, providing forward secrecy. All file data
    transferred after the handshake is encrypted with this key.

    The server listens for commands like SEND, GET, LIST, and DELETE.

    Attributes:
        allow_reuse_address (bool): Allows the server to restart and bind to the
                                    same address quickly.
        host (str): The IP address the server is bound to.
        port (int): The port the server is listening on.

    Supported Status Codes:
        - 200: Success
        - 226: Transfer Complete
        - 400: Bad Request / Invalid Command
        - 404: File Not Found
        - 500: Internal Server Error (e.g., Decryption Failed)

    The header format is:
        - SEND: SEND|filename|encrypted_data
        - GET: GET|filename
        - LIST: LIST
        - DELETE: DELETE|filename
    """
    allow_reuse_address = True

    def __init__(self, host: str='127.0.0.1', port: int=5001, logging: bool=True) -> None:
        """
        Initializes the server and binds it to a host and port.

        Args:
            host (str): The network interface IP to bind to. Defaults to '127.0.0.1'
                        (localhost). Use '0.0.0.0' to listen on all interfaces.
            port (int): The port number to listen on. Defaults to 5001.
            enable_logging (bool): If False, disables server logging output.
        """
        super().__init__((host, port), E2EEFTPRequestHandler)
        self.host, self.port = host, port
        self.enable_logging = logging
        log.disabled = not self.enable_logging

    def _generate_server_keys_if_missing(self) -> None:
        """
        Generates and saves server key pair if it doesn't exist.
        """
        server_key_path = "server_id.key"
        if not os.path.exists(server_key_path):
            log.warning(f"Server identity key '{server_key_path}' not found. Generating a new one.")

            server_priv_key = ed25519.Ed25519PrivateKey.generate()
            server_pub_key = server_priv_key.public_key()

            # Save server private key in PEM format
            with open(server_key_path, "wb") as f:
                f.write(server_priv_key.private_bytes(
                    encoding=serialization.Encoding.PEM,
                    format=serialization.PrivateFormat.PKCS8,
                    encryption_algorithm=serialization.NoEncryption()
                ))
            log.info(f"Saved '{server_key_path}' (private). This is your server's permanent identity.")

            # Save server public key in PEM format (for client's known_server.pub)
            with open("known_server.pub", "wb") as f:
                f.write(server_pub_key.public_bytes(
                    encoding=serialization.Encoding.PEM,
                    format=serialization.PublicFormat.SubjectPublicKeyInfo
                ))
            log.info("Saved 'known_server.pub' (public). Copy this file to your client's directory.")
            log.warning("You must still generate client keys and add their public keys to 'authorized_clients.pub' for them to connect.")

    def run(self) -> None:
        """
        Starts the server's main loop to listen for and handle connections.

        This method calls `serve_forever()`, which blocks and waits for incoming
        connections. Each connection is then passed to an instance of
        `E2EEFTPRequestHandler` for processing in a new thread.
        """
        self._generate_server_keys_if_missing()
        log.info(f"host: {self.host}, port: {self.port}")
        log.info("press ctrl+c to exit")
        log.info(f"Server listening on {self.host}:{self.port}")
        try:
            self.serve_forever()
        except KeyboardInterrupt:
            log.warning("Shutting down...")
        finally:
            self.server_close()

A multi-threaded TCP server for secure file transfers.

This server uses socketserver.ThreadingTCPServer to handle each incoming client connection in a separate thread. This allows for concurrent file transfer operations.

Security is established on a per-connection basis using an Elliptic Curve Diffie-Hellman (ECDH) key exchange. This generates a unique, ephemeral session key for each client, providing forward secrecy. All file data transferred after the handshake is encrypted with this key.

The server listens for commands like SEND, GET, LIST, and DELETE.

Attributes

allow_reuse_address : bool
Allows the server to restart and bind to the same address quickly.
host : str
The IP address the server is bound to.
port : int
The port the server is listening on.

Supported Status Codes: - 200: Success - 226: Transfer Complete - 400: Bad Request / Invalid Command - 404: File Not Found - 500: Internal Server Error (e.g., Decryption Failed)

The header format is: - SEND: SEND|filename|encrypted_data - GET: GET|filename - LIST: LIST - DELETE: DELETE|filename

Initializes the server and binds it to a host and port.

Args

host : str
The network interface IP to bind to. Defaults to '127.0.0.1' (localhost). Use '0.0.0.0' to listen on all interfaces.
port : int
The port number to listen on. Defaults to 5001.
enable_logging : bool
If False, disables server logging output.

Ancestors

  • socketserver.ThreadingTCPServer
  • socketserver.ThreadingMixIn
  • socketserver.TCPServer
  • socketserver.BaseServer

Class variables

var allow_reuse_address

The type of the None singleton.

Methods

def run(self) ‑> None
Expand source code
def run(self) -> None:
    """
    Starts the server's main loop to listen for and handle connections.

    This method calls `serve_forever()`, which blocks and waits for incoming
    connections. Each connection is then passed to an instance of
    `E2EEFTPRequestHandler` for processing in a new thread.
    """
    self._generate_server_keys_if_missing()
    log.info(f"host: {self.host}, port: {self.port}")
    log.info("press ctrl+c to exit")
    log.info(f"Server listening on {self.host}:{self.port}")
    try:
        self.serve_forever()
    except KeyboardInterrupt:
        log.warning("Shutting down...")
    finally:
        self.server_close()

Starts the server's main loop to listen for and handle connections.

This method calls serve_forever(), which blocks and waits for incoming connections. Each connection is then passed to an instance of E2EEFTPRequestHandler for processing in a new thread.