diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..b849933 --- /dev/null +++ b/.gitignore @@ -0,0 +1,2 @@ +ether/__pycache__/ +crypto/__pycache__/ diff --git a/client/__init__.py b/client/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/client/client.py b/client/client.py new file mode 100644 index 0000000..697d777 --- /dev/null +++ b/client/client.py @@ -0,0 +1,56 @@ +from abc import ABC, abstractmethod + +from models.identity import Identity + + +class Client(ABC): + """ + Abstract class to define the basics of a Dioxane Client + """ + + # Client information, doesn't interfer with Eth3r + name: str + version: str = "0.1" + + ether_version: int = 0x0001 # Which version of Eth3r it implements + identity: Identity # Key used by the client + + def __init__(self, identity: Identity, name: str = "DIOXANE"): + """ + Instantiate a client + :param identity: Key and KeyId of the client + :param name: Name of the client + """ + self.identity = identity + self.name = name + + @abstractmethod + def receive_pck(self, pck: bytearray): + """ + Process packet recieve from the server + :param pck: Packet + """ + pass + + @abstractmethod + def send_pck(self, pck: bytearray): + """ + Send Packet to server + :param pck: bytes to send + """ + pass + + def _exit(self, code: int = 0): + """ + Close the client + :param code: error code + """ + exit(code) + + @abstractmethod + def command(self, text: str): + """ + Execute a command + :param text: Command + """ + pass diff --git a/client/display_client.py b/client/display_client.py new file mode 100644 index 0000000..627795f --- /dev/null +++ b/client/display_client.py @@ -0,0 +1,19 @@ +from abc import ABC + +from client.client import Client +from display.pad_curses import PadCurses +from display.simple_curses import SimpleCurses +from models.identity import Identity + + +class DisplayClient(Client, ABC): + display: SimpleCurses + + def __init__(self, identity: Identity, name: str = "DIOXANE"): + super().__init__(identity, name) + + self.display = PadCurses(self) + + def _exit(self, code: int = 0): + self.display.exit() + super()._exit(code) diff --git a/client/io_client.py b/client/io_client.py new file mode 100644 index 0000000..bdc673c --- /dev/null +++ b/client/io_client.py @@ -0,0 +1,19 @@ +from abc import ABC +from threading import Thread + +from client.display_client import DisplayClient +from client.simple_client.input import Input +from models.identity import Identity + + +class IoClient(DisplayClient, ABC): + input: Input + intputThread: Thread + + def __init__(self, identity: Identity, name: str = "DIOXANE"): + super().__init__(identity, name) + + # Start the input thread + self.input = Input(self) + self.intputThread = Thread(target=self.input.start_listening) + self.intputThread.start() diff --git a/client/simple_client/__init__.py b/client/simple_client/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/client/simple_client/identity_manager.py b/client/simple_client/identity_manager.py new file mode 100644 index 0000000..e288955 --- /dev/null +++ b/client/simple_client/identity_manager.py @@ -0,0 +1,51 @@ +from exceptions.exceptions import UnknownRecipient +from misc.hex_enumerator import bytes_to_str +from models.identity import Identity + + +class IdentityManager: + + contactsByName: dict[str, str] # Map contacts name to their key_id + contactsByKey: dict[str, Identity] # Map key_id to Identity + + def __init__(self): + self.contactsByName = {} + self.contactsByKey = {} + + def getIdentity(self, identity: str | bytearray) -> Identity: + """ + Fetch the contact list for an Identity + :param identity: Name or key_id of the identity + :return: Identity + """ + key_id: str = "" + + if type(identity) is str: + # Trying by name + for name in self.contactsByName.keys(): + if identity in name: + return self.contactsByKey[self.contactsByName[name]] + + if identity[0:2] == "0x": + key_id = identity[2:] + + if type(identity) is bytearray: + key_id = bytes_to_str(identity)[2:] + + # Trying by key_id + try: + if key_id in self.contactsByKey: + return self.contactsByKey[key_id] + except ValueError: + pass + + raise UnknownRecipient(identity) + + def registerIdentity(self, identity: Identity): + """ + Insert an Identity in the contact list + :param identity: Identity to register + """ + key_id: str = bytes_to_str(identity.key_id)[2:] + self.contactsByKey[key_id] = identity + self.contactsByName[identity.name] = key_id diff --git a/client/simple_client/input.py b/client/simple_client/input.py new file mode 100644 index 0000000..53f1f9e --- /dev/null +++ b/client/simple_client/input.py @@ -0,0 +1,75 @@ +import curses + +from client.display_client import DisplayClient + + +class Input: + client: DisplayClient + listening: bool + prompt: str + prompt_cursor: int + + def __init__(self, client): + self.valid_char = list('/. ') + self.client = client + self._reset_prompt() + + def _reset_prompt(self): + self.prompt = "" + self.prompt_cursor = 0 + self.prompt_updated() + + def start_listening(self): + self.listening = True + self.prompt = "" + while self.listening: + char: int = self.client.display.input_windows.getch() + + if char == -1: + pass + else: + if char == 0xa: # Enter key + try: + self.client.command(self.prompt) + except Exception as e: + self.client.display.display_log(str(e)) + finally: + self._reset_prompt() + + elif char == curses.KEY_LEFT: + self.prompt_cursor -= 1 + if self.prompt_cursor < 0: + self.prompt_cursor = 0 + + elif char == curses.KEY_RIGHT: + self.prompt_cursor += 1 + if self.prompt_cursor > len(self.prompt): + self.prompt_cursor = len(self.prompt) + + elif char == curses.KEY_UP: + self.client.display.scroll(-1) + + elif char == curses.KEY_DOWN: + self.client.display.scroll(1) + + elif char == curses.KEY_SR: + self.client.display.scroll(-1) + + elif char == curses.KEY_SF: + self.client.display.scroll(1) + + elif char == 8: # Del key + self.prompt = self.prompt[:self.prompt_cursor-1] + self.prompt[self.prompt_cursor:] + self.prompt_cursor -= 1 + + elif char == curses.KEY_DC: + self.prompt = self.prompt[:self.prompt_cursor] + self.prompt[self.prompt_cursor+1:] + + elif chr(char).isalnum() or chr(char) in self.valid_char: + self.prompt = self.prompt[:self.prompt_cursor] + chr(char) + self.prompt[self.prompt_cursor:] + self.prompt_cursor += 1 + + self.prompt_updated() + + def prompt_updated(self): + self.client.display.update_prompt(self.prompt, self.prompt_cursor) diff --git a/client/simple_client/packet_generator.py b/client/simple_client/packet_generator.py new file mode 100644 index 0000000..377d469 --- /dev/null +++ b/client/simple_client/packet_generator.py @@ -0,0 +1,43 @@ +from client.client import Client +from models.identity import Identity +from models.packet import Packet, PacketCode +from models.room_message import RoomMessage + + +class PacketGenerator: + client: Client + + def __init__(self, client: Client): + self.client = client + + def get_hey_packet(self) -> Packet: + """ + Build a Hey packet to connect the client to the server + :return: Hey Packet + """ + return Packet(PacketCode.HEY, [self.client.ether_version.to_bytes(2, 'big')]) + + def get_send_key_packet(self) -> Packet: + """ + Build a Key Packet + :return: Key Packet + """ + return Packet(PacketCode.SEND_KEY, self.client.identity.get_key_pair()) + + # noinspection PyMethodMayBeStatic + def get_send_message_packet(self, message: RoomMessage) -> Packet: + """ + Build a Message Packet + :param message: Message to send + :return: Message Send Packet + """ + return Packet(PacketCode.MESSAGE_SEND, message.room.room_id.get_key_id_pair().append(message.payload)) + + # noinspection PyMethodMayBeStatic + def get_knock_packet(self, recipient: Identity) -> Packet: + """ + Build a Kncok Packet + :param recipient: Client to knock + :return: Knock Packet + """ + return Packet(PacketCode.KNOCK_SEND, recipient.get_key_id_pair()) diff --git a/client/simple_client/simple_client.py b/client/simple_client/simple_client.py new file mode 100644 index 0000000..30ef3a1 --- /dev/null +++ b/client/simple_client/simple_client.py @@ -0,0 +1,355 @@ +from threading import Thread + +from client.io_client import IoClient +from client.simple_client.packet_generator import PacketGenerator +from client.simple_client.identity_manager import IdentityManager +from client.simple_client.simple_client_connection_state import Server, ConnectionState +from misc.hex_enumerator import bytes_to_str, bytes_to_int, str_to_bytes +from models.identity import Identity +from models.knock import Knock, KnockState +from models.message import MessageType +from models.packet import Packet, PacketCode + +from exceptions.exceptions import MalformedPacketException, UnknownRecipient +from models.room import Room +from models.room_message import RoomMessage + + +class SimpleClient(IoClient, IdentityManager): + """ + SimpleClient is a basic implementation of Eth3r. + It works with a Display to output message and input prompt + It works along a Server, which handle a connection and the state of Eth3r's connection + """ + server: Server + + knocks: dict[Identity, Knock] + rooms: dict[Identity, Room] + roomRecipients: dict[Identity, Identity] # Map Recipient to their Room id + + serverThread: Thread + + packetGenerator: PacketGenerator + + auto_connect: bool = False + + def __init__(self, identity: Identity, name: str = "DIOXANE"): + super().__init__(identity, name) + + self.packetGenerator = PacketGenerator(self) + + # Start the server + self.server = Server(self) + self.server.connection.attach(self) + self.serverThread = Thread(target=self.server.connection.start_listening, daemon=True) + self.serverThread.start() + + self.knocks = {} + self.rooms = {} + self.roomRecipients = {} + + if self.auto_connect: + self.connect() + + def send_pck(self, pck: bytearray | Packet): + if type(pck) is Packet: + pck = pck.to_bytes() + self.display.display_debug("Sent " + bytes_to_str(pck)) + self.server.connection.send(pck) + + def receive_pck(self, pck: bytearray): + self.display.display_debug("Received " + bytes_to_str(pck)) + try: + if bytes_to_int(pck) == 0x0: + self._exit(0) + + self.interpret(Packet(pck)) + except MalformedPacketException as e: + self.display.display_error(str(e)) + except NotImplementedError as e: + self.display.display_error(str(e)) + except BaseException as e: + self.display.display_error(f"Unexpected error while reading {bytes_to_str(pck)} : {type(e)} {e}") + + def interpret(self, packet: Packet): + match packet.code: + case PacketCode.ACK: + self._match_ack_to_something(packet) + + case PacketCode.KNOCK_RECIEVE: + key_id_length, key_id = packet.options + try: + recipient = self.getIdentity(key_id) + + self.display.display_log( + f"Recieved a knock request from {recipient}.") + except UnknownRecipient: + recipient = Identity(key_id=key_id, key_id_length=key_id_length) + self.registerIdentity(recipient) + self.display.display_log( + f"Recieved a knock request from {recipient} but we don't know who it is.") + self.display.display_debug(f"We can use /rename {recipient} to set their name") + + self.knocks[recipient] = Knock(recipient) + self.knocks[recipient].state = KnockState.KNOCKING_RECEIVED + + case PacketCode.KNOCK_RESPONSE: + accepted = packet.options[0] == b'\x01' + key_id_length, key_id = packet.options[1], packet.options[2] + try: + recipient = self.getIdentity(key_id) + except UnknownRecipient: + self.display.display_error(f"Recieved a knock response from {bytes_to_str(key_id)}" + f"but we don't know who it is.") + return self.display.display_debug(f"Have we ever ask them in the first place ?") + + if recipient not in self.knocks: + return self.display.display_error(f"Recieving a {'positive' if accepted else 'negative'}" + f" knocking response from {recipient}" + f" but never asked them in the first place") + + if self.knocks[recipient].state == KnockState.KNOCKING: + self.display.display_debug(f'Server forwarded {recipient}\'s response to our knock but we never' + f'recieved ACK from the server itself.' + f'We won\'t mind and pretend it did') + self.knocks[recipient].state = KnockState.KNOCKING_ACK + + if self.knocks[recipient].state == KnockState.KNOCKING_ACK: + if accepted: + self.knocks[recipient].state = KnockState.KNOCK_ACCEPTED + return self.display.display_success(f"{recipient} accepted our knock") + else: + self.knocks[recipient].state = KnockState.KNOCK_REFUSED + return self.display.display_error(f"{recipient} refused our knock") + else: + return self.display.display_error(f"Recieving a {'positive' if accepted else 'negative'}" + f" knocking response from {recipient}" + f" but we were {self.knocks[recipient].state}") + case PacketCode.ROOM_NEW: + room_key_length, room_key, key_id_length, key_id = packet.options + try: + recipient = self.getIdentity(key_id) + except UnknownRecipient: + self.display.display_error( + f"Recieved a room with {bytes_to_str(key_id)} but we don't know who it is.") + return self.display.display_debug(f"Have we ever ask them in the first place ?") + + if recipient not in self.knocks: + self.display.display_error(f"Recieving a room with {recipient}" + f" but never asked them in the first place") + self.display.display_log(f"Room with {recipient} joined anyway") + self._join_room(recipient, room_key) + return + + match self.knocks[recipient].state: + case KnockState.KNOCK_REFUSED: + self.display.display_error(f"Server created a room with {recipient} despite we/they refused.") + self.display.display_log(f"Joining room with {recipient} anyway") + case KnockState.KNOCK_ACCEPTED: + self.display.display_log(f"Room with {recipient} successfully joined") + case _: + self.display.display_error(f"Server created a room with {recipient}" + f"but we were {self.knocks[recipient].state}") + self.display.display_log(f"Joining room with {recipient} anyway") + + self._join_room(recipient, room_key) + + case PacketCode.MESSAGE_SEND: + room_key_length, room_key, encryption, payload = packet.options + room_id = Identity(key_id=room_key, key_id_length=room_key_length) + if room_id not in self.rooms: + return self.display.display_error(f"Recieved a message in room {room_id} despite we are not in it :" + f" {payload.decode('ascii')}") + + if self.rooms[room_id].closed: + self.display.display_error(f"We recieved a message in room {room_id} " + f"but it was closed") + + message: RoomMessage = RoomMessage(room=room_id, + message_type=MessageType.RECEIVE, + payload=payload, ack=True) + self._display_message(message) + + case PacketCode.ROOM_CLOSE: + room_key_length, room_key = packet.options + room_id = Identity(key_id=room_key, key_id_length=room_key_length) + if room_id not in self.rooms: + return self.display.display_error(f"Server tells us it is closing room {room_id} " + f"despite we are not in it") + + if self.rooms[room_id].closed: + self.display.display_error(f"Server tells us it is closing room {room_id}" + f" despite it already being closed") + self.rooms[room_id].closed = True + + case _: + self.display.display_error(f"This version of client yet not know how to interpret {packet.code.name}") + + def _join_room(self, recipient: Identity, room_id: bytearray): + room_id = Identity(name=f"Room with {recipient.name}", key_id=room_id) + self.roomRecipients[room_id] = recipient + self.rooms[recipient] = Room(recipient, room_id) + + def _match_ack_to_something(self, packet: Packet): + """ + Find what is acknoledged by an ACK Packet. In fact, it will acknoledge the first task that was waiting for it. + Missing Packet can led to a missing acknoledgement and further ACK will be missunterpreted. + To fix in the eth3r protocol + :param packet: ACK Packet + """ + if self.server.state == ConnectionState.CONNECTION_INITIALIZED_VERSION: + return self.send_key() + + if self.server.state == ConnectionState.CONNECTION_INITIALIZED_KEY: + return self._validate_connection() + + for knock in self.knocks.values(): + if knock.state == KnockState.KNOCKING: + knock.state = KnockState.KNOCKING_ACK + return self.display.display_debug("Knock ack by server") + + for room in self.rooms.values(): + for message in room.messages: + if not message.ack: + message.ack = True + return self.display.update_message(message) + + self.display.display_debug(f"Received unexpected ACK : {packet}") + + def connect(self): + """ + Send a HEY packet to the server + """ + try: + self.display.display_log(f"Connecting as {self.identity}") + self.send_pck(self.packetGenerator.get_hey_packet()) + self.server.state = ConnectionState.CONNECTION_INITIALIZED_VERSION + except BaseException as e: + self.display.display_error(f"Can't connect to server : {str(e)}") + + def send_key(self): + """ + Send a Key packet to the server + """ + try: + self.display.display_debug(f"Send my key : {bytes_to_str(self.identity.key)}") + self.send_pck(self.packetGenerator.get_send_key_packet()) + self.server.state = ConnectionState.CONNECTION_INITIALIZED_KEY + except BaseException as e: + self.display.display_error(f"Can't connect to server : {str(e)}") + + def _validate_connection(self): + """ + Validate a connection to the server + """ + self.display.display_success("Connected") + self.server.state = ConnectionState.CONNECTED + + def knock(self, recipient: Identity): + """ + Send a Knock to someone + :param recipient: Recipient to knock + """ + self.display.display_log(f'Knocking {recipient}') + self.knocks[recipient] = Knock(recipient) + self.knocks[recipient].state = KnockState.KNOCKING + self.send_pck(self.packetGenerator.get_knock_packet(recipient)) + + def _display_message(self, message: RoomMessage): + """ + Display a message in a room + :param message: Message to display + """ + message.room.add_message(message) + self.display.display_message(message) + + def send_message_to(self, recipient: Identity, payload: bytearray | str): + """ + Send a message to someone + :param recipient: Recipient of the message + :param payload: payload + """ + if recipient not in self.roomRecipients: + return self.display.display_error(f"Room with {recipient} doesn't exists") + + room_id: Identity = self.roomRecipients[recipient] + + if self.rooms[room_id].closed: + return self.display.display_error(f"Room with {recipient} is closed") + + message: RoomMessage = RoomMessage(sender=self.identity, room=self.rooms[recipient], + message_type=MessageType.SENT, + payload=payload, ack=False) + self._display_message(message) + self.send_pck(self.packetGenerator.get_send_message_packet(message)) + + def command(self, text: str): + """ + Execute a command + :param text: Command + """ + options = text.split(" ") + command = options.pop(0) + self._command(command, options) + + def _command(self, command: str, options: list[str]): + """ + Execute a command + :param command: Command name + :param options: Options list + :return: + """ + match command: + case 'knock': + try: + self.knock(self.getIdentity(options[0])) + except UnknownRecipient as e: + self.display.display_error("Unknown recipient : " + str(e)) + except BaseException as e: + self.display.display_error(e) + + case 'echo': + if len(options) < 1: + return self.display.display_error("echo require a text to display") + self.display.display_message(' '.join(options)) + + case 'send': + if len(options) < 2: + return self.display.display_error("send require two arguments : and a message") + try: + room_id = self.getIdentity(options.pop(0)) + except UnknownRecipient as e: + return self.display.display_error("Unknown recipient : " + str(e)) + + text = " ".join(options) + self.send_message_to(room_id, text) + + case 'connect': + self.connect() + + case 'server': + self.server.connection.receive(b''.join(map(str_to_bytes, options))) + + case 'list': + if len(options) < 1: + options = ['contacts'] + match options[0]: + case 'contacts': + for contact in self.contactsByKey.values(): + self.display.display_log(f"{contact} : {bytes_to_str(contact.key_id)}") + case 'rooms': + for room in self.rooms.values(): + self.display.display_log(f"{room.name} : {room.closed=}") + case 'knocks': + for knock in self.knocks.values(): + self.display.display_log(f"{knock.recipient.name} : {knock.state}") + case 'help': + self.display.display_debug("connect") + self.display.display_debug("knock ") + self.display.display_debug("send ") + self.display.display_debug("list ") + self.display.display_debug("server ") + self.display.display_debug("echo ") + case _: + self.display.display_error("Unknown command") + self.display.display_log(command) diff --git a/client/simple_client/simple_client_connection_state.py b/client/simple_client/simple_client_connection_state.py new file mode 100644 index 0000000..3f6c848 --- /dev/null +++ b/client/simple_client/simple_client_connection_state.py @@ -0,0 +1,37 @@ +from enum import Enum, auto + +from client.client import Client +from connection.fake_server import FakeConnection + + +class ConnectionState(Enum): + DISCONNECTED = auto() + CONNECTION_INITIALIZED_VERSION = auto() + CONNECTION_INITIALIZED_VERSION_ACK = auto() + CONNECTION_INITIALIZED_KEY = auto() + CONNECTION_INITIALIZED_KEY_ACK = auto() + CONNECTED = auto() + + +class Server: + """ + Server is a class that handle both the communication (over internet/other medium) and the state of the connection + to the Eth3r server (I.E. whereas the client is properly authentificated or waiting for the response) + """ + connection: FakeConnection # A connection to operate over + state: ConnectionState # The state of the connection with the Eth3r server + client: Client # Reference back to the client + attempt: int # Connection attempt NOT_YET_IMPLEMENTED + errors: list[str] # List of error NOT_YET_IMPLEMENTED + + def __init__(self, client: Client, connection: FakeConnection = None): + """ + Instantiate the Server, with a Disconnected state + :param connection: Connection + :param client: Client + """ + if connection is None: + connection = FakeConnection() + self.connection = connection + self.client = client + self.state = ConnectionState.DISCONNECTED diff --git a/connection/__init__.py b/connection/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/connection/connection.py b/connection/connection.py new file mode 100644 index 0000000..ad76d2e --- /dev/null +++ b/connection/connection.py @@ -0,0 +1,45 @@ +from abc import ABC, abstractmethod + +from client.client import Client + + +class Connection(ABC): + """ + Abstract class that represent a way to connect to a server, send and receive packets. + It implements the Observer / Observable pattern + """ + """ + Remark: this class is a bit overkill, since we probably won't have multiple client listening to the same connection + """ + _observers: list[Client] + + def __init__(self): + self._observers = [] + + def attach(self, observer): + if observer not in self._observers: + self._observers.append(observer) + + def detach(self, observer): + if observer in self._observers: + self._observers.remove(observer) + + def _notify_observers(self, pck: bytearray): + for observer in self._observers: + observer.receive_pck(pck) + + @abstractmethod + def start_listening(self): + """ + Activate the connection and listen for incomming packet + """ + raise NotImplementedError + + @abstractmethod + def send(self, pck: bytearray): + """ + Send packet over the connection + :param pck: bytes to send + :return: + """ + raise NotImplementedError diff --git a/connection/fake_server.py b/connection/fake_server.py new file mode 100644 index 0000000..f0cc3a2 --- /dev/null +++ b/connection/fake_server.py @@ -0,0 +1,40 @@ +import time + +from connection.connection import Connection +from misc.hex_enumerator import int_to_bytes +from models.packet import PacketCode + + +class FakeConnection(Connection): + """ + FakeConnection is an implementation of Connection intended for debug purposes. + It won't actually connect to anything, and can be asked to send any packet. + It has a list of packets to send, and will send them over time (default delay is one every 2 sec) + It will reply to any packet recieved with an ACK (can be turned off during init) + """ + _packets: list[bytearray] # List of packet to send (FIFO) + _reply_with_ACK: bool = False # Reply packet recieved with ACK + pck + _delay: float = 0.1 # Time in second between two packet + + def __init__(self): + self._packets = [] + super().__init__() + + def start_listening(self): + while True: + if len(self._packets) > 0: + self._notify_observers(self._packets.pop(0)) + time.sleep(self._delay) + + def send(self, pck: bytearray): + # Supposely send them + + if self._reply_with_ACK: + self.receive(int_to_bytes(PacketCode.ACK.value) + pck) + + def receive(self, pck: bytearray): + """ + Ask the FakeConnection to simulate receiving a packet + :param pck: Packet to receive + """ + self._packets.append(pck) diff --git a/crypto/__init__.py b/crypto/__init__.py new file mode 100644 index 0000000..1b5c3fd --- /dev/null +++ b/crypto/__init__.py @@ -0,0 +1,5 @@ +# +# This file is part of the eTh3r project, written, hosted and distributed under MIT License +# - eTh3r network, 2023-2024 +# + diff --git a/display/__init__.py b/display/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/display/display.py b/display/display.py new file mode 100644 index 0000000..960e2d1 --- /dev/null +++ b/display/display.py @@ -0,0 +1,56 @@ +from abc import ABC, abstractmethod + +from client.client import Client +from models.room_message import RoomMessage + + +class Display(ABC): + """ + Abstract class to define the bases of a Display + It has to be able to display message and error/log/etc + It has to be able to display the prompt as it is typed + """ + client: Client + + def __init__(self, client: Client): + self.client = client + + @abstractmethod + def update_prompt(self, prompt: str, cursor: int): + """ + Display the prompt + :param prompt: prompt + :param cursor: position of the cursor, from 0 to len(prompt) + """ + raise NotImplementedError + + @abstractmethod + def display_message(self, message: RoomMessage | str): + raise NotImplementedError + + @abstractmethod + def update_message(self, message: RoomMessage | str): + raise NotImplementedError + + @abstractmethod + def display_error(self, text: str): + raise NotImplementedError + + @abstractmethod + def display_log(self, text: str): + raise NotImplementedError + + @abstractmethod + def display_debug(self, text: str): + raise NotImplementedError + + @abstractmethod + def display_success(self, text: str): + raise NotImplementedError + + @abstractmethod + def exit(self): + """ + Close the display + """ + raise NotImplementedError diff --git a/display/pad_curses.py b/display/pad_curses.py new file mode 100644 index 0000000..16407cc --- /dev/null +++ b/display/pad_curses.py @@ -0,0 +1,88 @@ +import curses + +from client.client import Client +from display.simple_curses import SimpleCurses, SimpleCursesColor +from models.message import MessageType +from models.room_message import RoomMessage + + +class PadCurses(SimpleCurses): + """ + Technical improvement to Simple Curses to use pad. + This allows scrolling + """ + message_pad_height: int = 100 + message_pad: "_CursesWindow" = None + prompt_windows: "_CursesWindow" = None + header_windows: "_CursesWindow" = None + windows_scroll: int = 0 + windows_scroll_jump: bool = False + + def __init__(self, client: Client): + super().__init__(client) + self.message_pad = curses.newpad(self.message_pad_height, self.width) + # self.stdscr.border('│', '│', '─', '─', '┌', '┐', '└', '┘') + self.header_windows = curses.newwin(1, self.width, 0, 0) + self.prompt_windows = curses.newwin(1, self.width, self.height - 1, 0) + self.prompt_windows.keypad(True) + self.prompt_windows.nodelay(True) + self.input_windows = self.prompt_windows + self._display_base_gui() + + def _display_all_messages(self): + self.message_pad.clear() + i: int = 0 + for message in self.messages: + self._display_message(message, i) + i += 1 + + def _display(self, text: str, height: int, color: SimpleCursesColor): + self.message_pad.addstr(height, 0, text, curses.color_pair(color) | self.color_variation[color]) + self._update_cursor() + self._update_scroll() + + def scroll(self, scroll_delta: int = 0): + if scroll_delta != 0: + self.windows_scroll = max(min(0, len(self.messages)), self.windows_scroll + scroll_delta) + self._update_scroll() + + def _update_scroll(self): + # noinspection PyArgumentList + self.message_pad.noutrefresh(self.windows_scroll, 0, 1, 0, self.height - 2, self.width - 1) + curses.doupdate() + + def _display_base_gui(self): + """ + Display the client base GUI + """ + if self.header_windows is None: + return + self._display_line(height=0, middle_txt=self.client.name, right_txt=self.client.version, + windows=self.header_windows) + self._display_all_messages() + self._display_line(height=0, left_txt="> ", fillingchar=" ", windows=self.prompt_windows) + + def update_prompt(self, prompt: str, cursor: int): + self.prompt_cursor = cursor + self._display_line(height=0, left_txt="> " + prompt, fillingchar=" ", windows=self.prompt_windows) + self._update_cursor() + + def display_message(self, message: RoomMessage | str): + if type(message) is str: + return self.display_message(RoomMessage(payload=message, message_type=MessageType.LOG)) + + self.messages.append(message) + if self.windows_scroll_jump or self.windows_scroll + 1 == len(self.messages) - (self.height - 2): + self.windows_scroll = max(0, len(self.messages) - (self.height - 2)) + + if len(self.messages) > self.message_pad_height: + self.messages = self.messages[self.message_pad_height - self.height:self.message_pad_height] + self.windows_scroll -= self.message_pad_height - self.height + self._display_all_messages() + else: + self._display_message(message, len(self.messages) - 1) + + def _update_cursor(self, y=0, x=None): + if x is None: + x = self.prompt_cursor + 2 + self.prompt_windows.move(y, x) diff --git a/display/simple_curses.py b/display/simple_curses.py new file mode 100644 index 0000000..1815241 --- /dev/null +++ b/display/simple_curses.py @@ -0,0 +1,193 @@ +import curses +from enum import IntEnum, auto + +from client.client import Client +from display.display import Display +from models.message import MessageType +from models.room_message import RoomMessage + + +class SimpleCursesColor(IntEnum): + CLASSIC = auto() + MESSAGE_SEND = auto() + MESSAGE_RECIEVE = auto() + SUCCESS = auto() + ERROR = auto() + DEBUG = auto() + LOG = auto() + + +class SimpleCurses(Display): + """ + + """ + width: int + height: int + message_count: int = 1 + prompt_cursor: int = 0 + + color_variation: dict[SimpleCursesColor] + + messages: list[RoomMessage] + input_windows: "_CursesWindow" + + def __init__(self, client: Client): + super().__init__(client) + + self.stdscr = curses.initscr() + self.input_windows = self.stdscr + curses.noecho() + curses.cbreak() + if curses.has_colors(): + curses.start_color() + curses.use_default_colors() + curses.init_pair(SimpleCursesColor.CLASSIC, curses.COLOR_WHITE, -1) + curses.init_pair(SimpleCursesColor.MESSAGE_SEND, curses.COLOR_BLUE, -1) + curses.init_pair(SimpleCursesColor.MESSAGE_RECIEVE, curses.COLOR_CYAN, -1) + curses.init_pair(SimpleCursesColor.SUCCESS, curses.COLOR_GREEN, -1) + curses.init_pair(SimpleCursesColor.ERROR, curses.COLOR_RED, -1) + curses.init_pair(SimpleCursesColor.DEBUG, curses.COLOR_YELLOW, -1) + curses.init_pair(SimpleCursesColor.LOG, curses.COLOR_WHITE, -1) + self.color_variation = {SimpleCursesColor.CLASSIC: 0, SimpleCursesColor.MESSAGE_SEND: 0, + SimpleCursesColor.MESSAGE_RECIEVE: 0, SimpleCursesColor.SUCCESS: curses.A_BOLD, + SimpleCursesColor.DEBUG: 0, SimpleCursesColor.ERROR: curses.A_BOLD, + SimpleCursesColor.LOG: curses.A_ITALIC} + + self.stdscr.keypad(True) + # self.stdscr.leaveok(True) + self.width = curses.COLS + self.height = curses.LINES + self.messages = [] + self.message_pad = curses.newpad(self.width, 100) + + self._display_base_gui() + + def _display(self, text: str, height: int, color: SimpleCursesColor): + self.stdscr.addstr(height, 0, text, curses.color_pair(color) | self.color_variation[color]) + self._update_cursor() + self.stdscr.refresh() + + def _get_line(self, left_txt: str = "", middle_txt: str = "", right_txt: str = "", + space_between: bool = True, fillingchar: str = "=") -> str: + + first_half: int = int(self.width / 2) - int(len(middle_txt) / 2) - len(left_txt) - (2 if space_between else 0) + second_half: int = self.width - len(left_txt + middle_txt + right_txt) - (4 if space_between else 0) \ + - first_half - 1 + return left_txt + (" " if space_between else "") \ + + fillingchar * first_half + (" " if space_between else "") \ + + middle_txt + (" " if space_between else "") \ + + fillingchar * second_half + (" " if space_between else "") \ + + right_txt + + def _display_line(self, height: int = 0, left_txt: str = "", middle_txt: str = "", right_txt: str = "", + space_between: bool = True, fillingchar: str = "=", windows=None): + """ + Display a full line + :param height: Position of the line + :param left_txt: Text on the left + :param middle_txt: Text in the middle + :param right_txt: Text on the right + :param space_between: Whether or not to add space around text (default is true) + :param fillingchar: Char used to fill the line (default is =) + """ + text: str = self._get_line(left_txt, middle_txt, right_txt, space_between, fillingchar) + windows.addstr(height, 0, text) + self._update_cursor() + windows.refresh() + + def _display_all_messages(self): + i: int = 1 + for message in self.messages: + self._display_message(message, i) + i += 1 + + def _display_message(self, message: RoomMessage, height: int): + + header = "" + color = SimpleCursesColor.CLASSIC + + if message.message_type == MessageType.ERROR: + header = f"/!\\ " + color = SimpleCursesColor.ERROR + + if message.message_type == MessageType.LOG: + header = f"- " + color = SimpleCursesColor.LOG + + if message.message_type == MessageType.DEBUG: + header = f"~ " + color = SimpleCursesColor.DEBUG + + if message.message_type == MessageType.SUCCESS: + header = f"[✔] " + color = SimpleCursesColor.SUCCESS + + if message.message_type == MessageType.SENT: + if message.ack: + header = f"[To {message.room.recipient}] " + else: + header = f"(To {message.room.recipient}) " + color = SimpleCursesColor.MESSAGE_SEND + + if message.message_type == MessageType.RECEIVE: + header = f"[From {message.room.recipient}] " + color = SimpleCursesColor.MESSAGE_RECIEVE + + self._display(header + message.payload.decode("ascii"), height, color) + + def _display_base_gui(self): + """ + Display the client base GUI + """ + self._display_line(height=0, middle_txt=self.client.name, right_txt=self.client.version, windows=self.stdscr) + self._display_all_messages() + self._display_line(height=self.height - 1, left_txt="> ", fillingchar=" ", windows=self.stdscr) + + def update_prompt(self, prompt: str, cursor: int): + self.prompt_cursor = cursor + self._display_line(height=self.height - 1, left_txt="> " + prompt, fillingchar=" ", windows=self.stdscr) + self._update_cursor() + + def display_message(self, message: RoomMessage | str): + if type(message) is str: + return self.display_message(RoomMessage(payload=message, message_type=MessageType.LOG)) + + self.messages.append(message) + if len(self.messages) > self.height - 2: + self.messages.pop(0) + self._display_all_messages() + else: + self._display_message(message, len(self.messages)) + + def display_error(self, text: str): + return self.display_message(RoomMessage(payload=text, message_type=MessageType.ERROR)) + + def display_log(self, text: str): + return self.display_message(RoomMessage(payload=text, message_type=MessageType.LOG)) + + def display_debug(self, text: str): + return self.display_message(RoomMessage(payload=text, message_type=MessageType.DEBUG)) + + def display_success(self, text: str): + return self.display_message(RoomMessage(payload=text, message_type=MessageType.SUCCESS)) + + def update_message(self, message: RoomMessage): + try: + index: int = self.messages.index(message) + self._display_message(message, index) + except ValueError: + self.display_error(f"Trying to update message but can't find it : {message.payload.decode('ascii')}") + + def _update_cursor(self, y=0, x=None): + if x is None: + x = self.prompt_cursor + 2 + self.stdscr.move(y, x) + + def exit(self): + curses.nocbreak() + curses.echo() + self.stdscr.keypad(False) + curses.endwin() + + def scroll(self, value: int): + pass diff --git a/exceptions/__init__.py b/exceptions/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/exceptions/exceptions.py b/exceptions/exceptions.py new file mode 100644 index 0000000..2323011 --- /dev/null +++ b/exceptions/exceptions.py @@ -0,0 +1,14 @@ +class EndOfEnumerator(BaseException): + pass + + +class MalformedPacketException(BaseException): + pass + + +class UnknownRecipient(BaseException): + pass + + +class MalformedMessageException(Exception): + pass diff --git a/identity_provider/__init__.py b/identity_provider/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/identity_provider/gpg.py b/identity_provider/gpg.py new file mode 100644 index 0000000..d7bb9b3 --- /dev/null +++ b/identity_provider/gpg.py @@ -0,0 +1,26 @@ +# +# This file is part of the eTh3r project, written, hosted and distributed under MIT License +# - eTh3r network, 2023-2024 +# + +import gnupg + +from identity_provider.identity_provider import IdentityProvider +from models.identity import Identity + +gpg = gnupg.GPG() + + +# UNTESTED +class GPGKeyProvider(IdentityProvider): + @staticmethod + def getIdentity(identity: str) -> Identity: + # TODO : This is only the backbone of GPG Key Provider + # It hasn't been verified and may not even work + key = gpg.export_keys(identity, False) + key_id = int(f"0x1312") # Todo : use gpg to get key_id + return Identity(name=identity, + key_length=len(hex(key))-2, + key=hex(key), + key_id_length=len(hex(key_id))-2, + key_id=hex(key_id)) diff --git a/identity_provider/identity_provider.py b/identity_provider/identity_provider.py new file mode 100644 index 0000000..f61c957 --- /dev/null +++ b/identity_provider/identity_provider.py @@ -0,0 +1,10 @@ +from abc import ABC, abstractmethod + +from models.identity import Identity + + +class IdentityProvider(ABC): + @staticmethod + @abstractmethod + def getIdentity(identity: str) -> Identity: + pass diff --git a/identity_provider/simple_key.py b/identity_provider/simple_key.py new file mode 100644 index 0000000..5d9d1fe --- /dev/null +++ b/identity_provider/simple_key.py @@ -0,0 +1,16 @@ +from identity_provider.identity_provider import IdentityProvider +from models.identity import Identity + + +class SimpleKeyProvider(IdentityProvider): + @staticmethod + def getIdentity(identity: str) -> Identity: + return Identity(name=identity, + key_length=4, + key=int(f"0x1312{identity}"), + key_id_length=2, + key_id=int(f"0x{identity}")) + + +baba: Identity = SimpleKeyProvider.getIdentity("baba") +fefe: Identity = SimpleKeyProvider.getIdentity("fefe") diff --git a/main.py b/main.py new file mode 100644 index 0000000..52692cb --- /dev/null +++ b/main.py @@ -0,0 +1,12 @@ +from client.simple_client.simple_client import SimpleClient +from models.identity import Identity + +if __name__ == '__main__': + me: Identity = Identity(name="Amus", key_id_length=2, key_id=0x1312, key_length=4, key=0x1312b00b) + + baophes: Identity = Identity(key_id=0x964d, name="Baophes") + cysalia: Identity = Identity(key_id=0xa156, name="Cysalia") + + client = SimpleClient(me, "DIOXANE") + client.registerIdentity(baophes) + client.registerIdentity(cysalia) diff --git a/misc/__init__.py b/misc/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/misc/hex_enumerator.py b/misc/hex_enumerator.py new file mode 100644 index 0000000..c5d7a03 --- /dev/null +++ b/misc/hex_enumerator.py @@ -0,0 +1,68 @@ +from exceptions.exceptions import EndOfEnumerator + + +def int_to_bytes(value: int) -> bytearray: + """ + Cast an int into a bytearray + :param value: + :return: + """ + hexstr = hex(value) + if len(hexstr) % 2 == 1: + hexstr = "0x0" + hexstr[2:] + return str_to_bytes(hexstr) + + +def bytes_to_int(value: bytearray) -> int: + """ + Cast a byte array into int + :param value: Byte array + :return: Int + """ + return int.from_bytes(value, 'big') + + +def str_to_bytes(value: str) -> bytearray: + """ + Cast a hex string into a bytearray + :param value: String containing hex digits, starting with "0x" or not + :return: Byte array + """ + if value[0:2] == '0x': + return bytearray.fromhex(value[2:]) + else: + return bytearray.fromhex(value) + + +def bytes_to_str(value: bytearray) -> str: + """ + Cast a bytearray into a hex string + :param value: Byte array + :return: String formated as 0x.... + """ + return hex(int.from_bytes(value, 'big')) + + +def read_bytes(bytesEnumerator: enumerate, size: int | bytearray = 1, value: bytearray = bytearray(0)) -> bytearray: + """ + Read bytes from an enumerator and return them as a bytearray + :param bytesEnumerator: enumerator + :param size: how many bytes to read from + :param value: precedent bytes to add in the bytearray returned (before the read bytes) + :return: Byte array + """ + if type(size) == bytearray: + size = bytes_to_int(size) + + _value = value.copy() + + for i in range(size): + + pos, nextByte = next(bytesEnumerator, (None, None)) + + if nextByte is None: + raise EndOfEnumerator + + _value.append(nextByte) + + return _value diff --git a/models/__init__.py b/models/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/models/identity.py b/models/identity.py new file mode 100644 index 0000000..1acef08 --- /dev/null +++ b/models/identity.py @@ -0,0 +1,69 @@ +from misc.hex_enumerator import bytes_to_str, int_to_bytes + + +class Identity: + """ + Hold the information about a key pair + """ + name: str + key: bytearray + key_length: int + key_id: bytearray + key_id_length: int + + def __init__(self, key_id: int | bytearray, key_id_length: int = None, + key_length: int = None, key: int | bytearray = None, + name: str = None): + if type(key_id) == int: + if key_id_length is None: + key_id = int_to_bytes(key_id) + else: + key_id = key_id.to_bytes(key_id_length, 'big') + if type(key) == int: + if key_length is None: + key = int_to_bytes(key) + else: + key = key.to_bytes(key_length, 'big') + + if key_id is not None and key_id_length is None: + key_id_length = len(key_id) + + if key is not None and key_length is None: + key_length = len(key) + + self.name = name or bytes_to_str(key_id) + self.key = key + self.key_length = key_length + self.key_id = key_id + self.key_id_length = key_id_length + + def set_key(self, length: int, key: int): + self.key_length = length + self.key = key + + def get_key_bytes(self): + return int_to_bytes(self.key_length).append(self.key) + + def get_key_pair(self) -> list[bytearray]: + return [int_to_bytes(self.key_length), self.key] + + def get_key_id_bytes(self): + return int_to_bytes(self.key_id_length).append(self.key_id) + + def get_key_id_pair(self) -> list[bytearray]: + return [int_to_bytes(self.key_id_length), self.key_id] + + def __repr__(self): + if self.key is None: + return f"[{self.name} : {bytes_to_str(self.key_id)} -> ???]" + else: + return f"[{self.name} : {bytes_to_str(self.key_id)} -> {bytes_to_str(self.key)}]" + + def __str__(self): + return self.name + + def __eq__(self, other): + return hash(self) == hash(other) + + def __hash__(self): + return hash(bytes_to_str(self.key_id)) diff --git a/models/knock.py b/models/knock.py new file mode 100644 index 0000000..548e279 --- /dev/null +++ b/models/knock.py @@ -0,0 +1,25 @@ +from enum import Enum, auto + +from models.identity import Identity + + +class KnockState(Enum): + KNOCKING = auto() + KNOCKING_ACK = auto() + KNOCKING_RECEIVED = auto() + KNOCK_ACCEPTED = auto() + KNOCK_REFUSED = auto() + + +class Knock: + """ + Basic class to keep track of the state of a Knock + """ + state: KnockState # State of the room + # Recipient of the room has to be saved client side so we can know afterward who is talking in the room + recipient: Identity + + def __init__(self, recipient: Identity): + self.recipient = recipient + self.state = KnockState.KNOCKING + diff --git a/models/message.py b/models/message.py new file mode 100644 index 0000000..04927e2 --- /dev/null +++ b/models/message.py @@ -0,0 +1,50 @@ +import random +from enum import Enum, auto + +from models.identity import Identity + + +class MessageType(Enum): + SENT = auto() + RECEIVE = auto() + SUCCESS = auto() + LOG = auto() + DEBUG = auto() + ERROR = auto() + + +class Message: + """ + Generic class to define a Message structure + It must contains a payload + It can have a sender, a recipient + """ + payload: bytearray # Content of the message + sender: Identity # Sender + recipient: Identity # Recipient + ack: bool # Was it ACK by the server (for sent message) + message_type: MessageType # Type + id: int # Random int + + def __init__(self, payload: bytearray | str, sender: Identity = None, recipient: Identity = None, + ack: bool = True, message_type: MessageType = None): + """ + Create a message, whome payload can be define by a bytearray or a str (will be cast in ascii bytesarray) + :param payload: Byte array or string + :param sender: Sender + :param recipient: Recipient + :param ack: ACK by the server (Default is True) + :param message_type: Type + """ + if type(payload) is str: + payload = bytearray(payload.encode()) + + self.id = random.getrandbits(10) + self.payload = payload + self.sender = sender + self.recipient = recipient + self.ack = ack + self.message_type = message_type + + def __hash__(self): + return hash(self.id) diff --git a/models/packet.py b/models/packet.py new file mode 100644 index 0000000..9b12b02 --- /dev/null +++ b/models/packet.py @@ -0,0 +1,157 @@ +from enum import IntEnum + +from exceptions.exceptions import MalformedPacketException, EndOfEnumerator +from misc.hex_enumerator import read_bytes, int_to_bytes, str_to_bytes, bytes_to_str, bytes_to_int + + +class PacketCode(IntEnum): + HEY = 0x0531b00b + ACK = 0xa0 + SEND_KEY = 0x0e1f + + KNOCK_SEND = 0xee + KNOCK_RECIEVE = 0xae + KNOCK_RESPONSE = 0xab + ROOM_NEW = 0xac + ROOM_CLOSE = 0xaf + + KEY_REQUEST = 0xba + KEY_RESPONSE = 0xa0ba + KEY_UNKNOWN = 0xca + + MESSAGE_SEND = 0xda + + ERR_WRONG_PACKET_LENGTH = 0xa1 + ERR_WRONG_PACKET_ID = 0xa2 + ERR_UNSUPPORTED_VERSION = 0xa4 + ERR_INCOMPLETE_KEY = 0xaa + ERR_KEY_MALFORMED = 0xab + ERR_KEY_PAYLOAD_MALFORMED = 0xac + ERR_MALFORMATION = 0xba + ERR_OUT_OF_PATH = 0xe0 + ERR_UNKNOWN_PACKET_ID = 0xfd + ERR_NOT_IMPLEMENTED = 0xfe + ERR_FAULTY_READING = 0xff + + +class Packet: + """ + Eth3r Packet + """ + code: PacketCode + options: list[bytearray] + + def __init__(self, payload: bytearray | bytes | int | str | PacketCode, options: list[bytearray] = None): + + if options is None: + options = [] + self.options = options.copy() + bytesEnumerator: enumerate + + if type(payload) is PacketCode: + self.code = payload + + if type(payload) is int: + try: + payload = int_to_bytes(payload) + except ValueError: + raise MalformedPacketException("Non-hexadecimal number in payload") + + if type(payload) is str: + try: + payload = str_to_bytes(payload) + except ValueError: + raise MalformedPacketException("Non-hexadecimal number in payload") + + if type(payload) is bytearray or type(payload) is bytes: + bytesEnumerator = enumerate(payload) + self._set_code_from_enumerator(bytesEnumerator) + try: + self._set_options_from_enumerator(bytesEnumerator) + except EndOfEnumerator: + raise MalformedPacketException("Packet options doesn't match expected size of " + + self.code.name + " : " + bytes_to_str(payload)) + + def _set_code_from_enumerator(self, bytesEnumerator): + code = bytearray(b'') + while bytes_to_int(code) not in PacketCode.value2member_map_: + try: + code = read_bytes(bytesEnumerator, 1, code) + except EndOfEnumerator: + raise MalformedPacketException("PacketCode doesn't correspond to an implemented code : " + + bytes_to_str(code)) + self.code = PacketCode(bytes_to_int(code)) + + def _set_options_from_enumerator(self, bytesEnumerator): + match self.code: + case PacketCode.HEY: + self.options += read_bytes(bytesEnumerator, 2) + + case PacketCode.ACK: + try: + nextByte = read_bytes(bytesEnumerator) + + # An KEY_RESPONSE PacketCode can be confused with an ACK as they both start with 0xa0 + # Thus ACK followed by 0xba are converted into a simple KEY_RESPONSE + if nextByte == int_to_bytes(0xba): + self.code = PacketCode.KEY_RESPONSE + else: + while True: + try: + nextByte = read_bytes(bytesEnumerator, 1, value=nextByte) + except EndOfEnumerator: + break + self.options.append(nextByte) + except EndOfEnumerator: + pass + + case PacketCode.SEND_KEY | PacketCode.KEY_RESPONSE: + key_length = read_bytes(bytesEnumerator, 2) + self.options += [key_length, read_bytes(bytesEnumerator, key_length)] + + case PacketCode.KNOCK_SEND | PacketCode.KNOCK_RECIEVE | PacketCode.ROOM_CLOSE | PacketCode.KEY_REQUEST \ + | PacketCode.KEY_UNKNOWN: + key_length = read_bytes(bytesEnumerator, 1) + self.options += [key_length, read_bytes(bytesEnumerator, key_length)] + + case PacketCode.KNOCK_RESPONSE: + response = read_bytes(bytesEnumerator, 1) + key_length = read_bytes(bytesEnumerator, 1) + self.options += [response, key_length, read_bytes(bytesEnumerator, key_length)] + + case PacketCode.ROOM_NEW: + room_length = read_bytes(bytesEnumerator, 1) + room_id = read_bytes(bytesEnumerator, room_length) + + key_length = read_bytes(bytesEnumerator, 1) + key_id = read_bytes(bytesEnumerator, key_length) + + self.options += [room_length, room_id, key_length, key_id] + + case PacketCode.MESSAGE_SEND: + room_length = read_bytes(bytesEnumerator, 1) + + room_id = read_bytes(bytesEnumerator, room_length) + encryption = read_bytes(bytesEnumerator, 1) + + payload = bytearray(b'') + while True: + try: + payload = read_bytes(bytesEnumerator, 1, value=payload) + except EndOfEnumerator: + break + + self.options += [room_length, room_id, encryption, payload] + + # Error + case PacketCode.ERR_WRONG_PACKET_LENGTH | PacketCode.ERR_WRONG_PACKET_ID | \ + PacketCode.ERR_UNSUPPORTED_VERSION | PacketCode.ERR_INCOMPLETE_KEY | PacketCode.ERR_KEY_MALFORMED | \ + PacketCode.ERR_KEY_PAYLOAD_MALFORMED | PacketCode.ERR_MALFORMATION | PacketCode.ERR_OUT_OF_PATH | \ + PacketCode.ERR_UNKNOWN_PACKET_ID | PacketCode.ERR_NOT_IMPLEMENTED | PacketCode.ERR_FAULTY_READING: + raise NotImplementedError(f"Reading a packet {self.code.name} is not yet implemented") + + def to_bytes(self): + return int_to_bytes(self.code) + b"".join(self.options) + + def __str__(self): + return "" diff --git a/models/room.py b/models/room.py new file mode 100644 index 0000000..2dc09f5 --- /dev/null +++ b/models/room.py @@ -0,0 +1,34 @@ +from models.identity import Identity +from models.message import Message, MessageType + + +class Room: + """ + Basic class to keep track of the state of a Room + """ + name: str # Client side name, doesn't interfer with Eth3r + closed: bool # State of the room + # Recipient of the room has to be saved client side so we can know afterward who is talking in the room + recipient: Identity + messages: list[Message] # List of message in the room + room_id: Identity # Id of the room server side + + def __init__(self, recipient: Identity, room_id: Identity): + self.recipient = recipient + self.name = str(recipient) + self.closed = False + self.messages = [] + self.room_id = room_id + + def add_message(self, message: Message): + """ + Add message in the room + :param message: Message + """ + if message.message_type is None: + if message.recipient == self.recipient: + message.message_type = MessageType.SENT + if message.sender == self.recipient: + message.message_type = MessageType.RECEIVE + + self.messages.append(message) diff --git a/models/room_message.py b/models/room_message.py new file mode 100644 index 0000000..df50329 --- /dev/null +++ b/models/room_message.py @@ -0,0 +1,24 @@ +from models.identity import Identity +from models.message import MessageType, Message +from models.room import Room + + +class RoomMessage(Message): + """ + Message that can be in a room + """ + room: Room # Room where it was sent/recieved + + def __init__(self, payload: bytearray | str, sender: Identity = None, recipient: Identity = None, + room: Room = None, ack: bool = True, message_type: MessageType = None): + """ + Create a message, whome payload can be define by a bytearray or a str (will be cast in ascii bytesarray) + :param payload: Byte array or string + :param sender: Sender + :param recipient: Recipient + :param room: Room + :param ack: ACK by the server (Default is True) + :param message_type: Type + """ + super().__init__(payload, sender, recipient, ack, message_type) + self.room = room