diff --git a/README.md b/README.md index 87b9f59..45ab0bd 100644 --- a/README.md +++ b/README.md @@ -1,59 +1,107 @@ -# pesupy-chat +# Account System -The project aims to create an end-to-end encrypted chat platform that enables users to securely exchange text messages. It consists of a Server program, which runs on a network-connected computer, and a command-line Client application for users to connect, create accounts, and engage in secure chatting with other account holders on the server. +## with Session Token based Authentication and End-to-End Encrypted Traffic -### Server Program +*** -The Server is a socket server that accepts packets from the Client, performing various operations as requested, including account creation, login, and message transmission. Users interested in hosting their own server can execute this on their computer. +### Setting up the Server -### Client Application +#### Tested and written in Python 3.11. Older versions will not work. Support for arm64 subject to availability of pre-built wheels for the required packages. -The Client program is a command-line interface that provides end-users with a straightforward experience. Upon execution, it displays a Sign Up/Login screen where users can create new accounts or log in to existing ones. Post-login, users can seamlessly communicate with others who have accounts on the server. +Create a venv and run `pip install -r requirements.txt` inside it before following the next steps. -### Security Measures +On the first execution of the server program, it asks the user for a folder where the server can store its operation files. In cases where the terminal can open a GUI, it opens a tkinter file selector dialogue and wherever it cannot do that, the user will be asked to input the file path in the command-line itself. -Client messages are securely transmitted using end-to-end encryption, ensuring data confidentiality between the Client, Server, and recipients. Account credentials are also transported securely using the same encryption method to prevent unauthorized access to user accounts. Additionally, account credentials and chat backups are stored in an encrypted format within the Server's MySQL database, providing an extra layer of security. +The user is asked for a password to lock the server to protect it from unauthorized access. This password will be used to encrypt the database credentials so that malicious actors with unauthorized access cannot hack into the database. -## Explanation & Installation +Then the database credentials, namely the MySQL/MariaDB server address, username, and password, are asked. The server then creates the necessary database and tables. -### End-to-End Encryption +The server program then asks for the network address and port on which the server should listen to connections. This port must be open to the public on the server side to allow connections. -End-to-end encryption (E2EE) is a robust data encryption method that ensures only the sender and intended recipient can access the data. This is achieved through a pair of mathematically linked keys: a public key used for encryption and a private key used for decryption. E2EE ensures privacy and security by never storing private keys on third-party servers, preventing even service providers from decrypting messages. +The server program will then close itself and will be ready for operation. When the server program is executed again, it asks for the password you entered. If a wrong password is entered, the server program closes at once. -### Server Setup +When the correct password is entered, the server starts to listen for connections. At this state, users can connect to the server and perform their operations (sign up, login, authenticate and logout). -The server setup process involves the following steps: +*** -1. Selection of a folder for server operation files. -2. Creation of MySQL schemas and tables. -3. Generation of an encryption keypair for secure traffic. -4. Password creation for server access protection. -5. Configuration of the network port for incoming connections. +### Problem Definition -Once set up, the server listens for connections, enabling users to sign up, log in, and send and receive messages. +To create a User Account Management System which can be used in various applications for safe and secure User Accounts. -### Key Pair Usage +This project consists of a Server program which can be executed on any computer with a network connection, and an example command-line Client, which users can use to connect to a server, create an account in the server and log into it. -To enhance security, this project employs an additional layer of end-to-end encryption, as it does not utilize SSL/TLS due to port restrictions and certificate complexities. This ensures absolute security during data transmission. +The Server program is a WebSocket server which accepts packets from the client and performs the operations mentioned by the client (such as creating account, logging in, authenticating, etc.). This Server program is the base of the account management system. -### Account Creation +The Client program is a command-line application for demonstrating the working of the server. When executed, the program displays an interactive command-line menu from where you can create an account, log in to an existing account or log out of the current session if logged in. -Users can execute the Client program to connect to a hosted server. The account creation process involves entering basic information, such as full name, email (optional), username, and password. The server securely stores this data in its MySQL database and generates a chat encryption keypair for each user. +The messages exchanged between the Client and the Server are encrypted, meaning no one in the middle can intercept the packet and view user details. -### Initiating a Chat +The account credentials are safely stored in the server computer’s MySQL/MariaDB database in an encrypted format, which provides an additional level of security from the server’s owner/malicious actors. -Once logged in, users access the main interface, where they can see their chats and start new conversations. They can enter the username of the person they want to chat with and engage in secure messaging. The Client uses SQLite for local data storage. +This project aims to provide other developers with a secure way of handling accounts in their applications, helping them focus on the main functionality of their application. -## Future Enhancements +This system can be implemented in various applications, such as chat platforms, social media platforms, and any other websites that require account creation. -The project's future enhancements may include: +### Problem Analysis -- Development of a GUI-based Client for user-friendliness. -- Mobile platform Clients (e.g., Android). -- Support for formatted text and various message types (voice, image, video). -- Group chat and file sharing features with end-to-end encryption. -- Cross-server messaging for increased flexibility and security. +#### Encrypted Traffic: -This open-source project empowers users to control their chat server's security and functionality while providing a user-friendly experience. +In cryptography, a key is a piece of information, usually a string of numbers or letters, that when processed through a cryptographic algorithm, can encode or decode cryptographic data. The key is used to transform data from plaintext (the original data) to ciphertext (the encrypted data). There are different methods for utilizing keys and encryption: + +- Symmetric cryptography: The same key is used for both encryption and decryption. + +- Asymmetric cryptography: Separate keys are used for encrypting and decrypting. These keys are known as the public and private keys. + +Encrypting data is essential so that only the sender and intended recipient (in this case, the server) can read it. The type of encryption used in the client-server connection system of this project is based on asymmetric cryptography. The public key is used to encrypt the data, and the private key is used to decrypt it. The public key is shared with the recipient, but the private key is kept secret by the sender. The asymmetric encryption algorithm this project uses is RSA (Rivest-Shamir-Adleman). + +When a message is sent by the server, it is encrypted using the recipient's public key. The message can only be decrypted using the recipient's private key. Similarly, messages sent to the server by the client are encrypted using + +the server’s public key. This means that even if an attacker intercepts the message, they will not be able to read it without the private key. In this application, the server’s public key will change with every restart for additional security. This diagram may give you more clarity: + +![image](https://github.com/user-attachments/assets/92820fbd-ae36-47f6-adcf-992a662fbeab) + + +This project does not utilize SSL/TLS or other methods of encrypting the connection as it requires special ports (number 80 and 443) to be open, and additional setup such as obtaining a certificate. Most consumer-oriented Internet Service Providers (ISPs) block the users from opening these ports, to prevent misuse. The process of obtaining a certificate is also tedious. This prevents the developers with not enough resources to buy a server from a hosting service/get their own enterprise network solution, from hosting this server. Therefore, the connection is encrypted with a different method to ensure security. + +#### WebSocket Server: + +WebSocket is a computer communications protocol that provides simultaneous two-way communication channels over a single Transmission Control Protocol (TCP) connection. Unlike HTTP, which is unidirectional, WebSocket is bidirectional and full duplex. This means the connection + +between the client and the server is kept alive until it is terminated by either party. WebSocket URIs start with ws:// + +It facilitates real-time data transfer from and to the server. This is made possible by providing a standardized way for the server to send content to the client without being first requested by the client and allowing messages to be passed back and forth while keeping the connection open. + +This is implemented in python by the websockets library built on top of python’s standard asynchronous input/output framework, asyncio. + +#### Asynchronous Operations + +Asynchronous operations are tasks that can run concurrently without blocking or waiting for each other. They are useful for improving the performance and responsiveness of IO-bound applications, such as network and web servers, database connections, etc. Asynchronous operations can be executed using the async/await syntax, which allows writing code that looks like synchronous code, but is asynchronous. + +asyncio is a built-in library in Python that provides support for asynchronous programming. It offers a framework and tools for creating and managing event loops, coroutines, tasks, futures, streams, transports, protocols, queues, and synchronization primitives. It also provides compatibility with other libraries and frameworks that use callbacks or generators. + +websockets library is built on top of asyncio for its very function of asynchronous programming. WebSocket servers need to be able to handle multiple connections at once which is not possible synchronously. One connection would block the others, waiting for its own job to be complete. Asynchronous processing eliminates this block and allows for simultaneous connections and better performance. + +#### Hashing and Salting + +Hashing is a process used in password security that involves converting a password into an unrecognizable series of characters. This is done using an irreversible hash function, which is a specialized algorithm. Instead of storing the password as plain text, a mathematical algorithm converts the password into a unique code. This unique code, or hash, is what gets stored in the database. + +Salting is a technique used in password security to enhance the protection of passwords stored in a database. It involves adding a unique, random string of characters, known as a salt, to each password before it is hashed. This process changes the hash of the password, making it more secure. + +Before the password is hashed, a salt value is added to it. This salted password is then hashed and stored in the database. Salting ensures that even if two users have the same password, their hashes will be different due to the unique salts. This halts attacks using precomputed tables of hashes, known as rainbow tables. + +Moreover, salting makes it extra difficult for an attacker who gains access to password hashes to find out the original password. Even if an attacker manages to decrypt a salted password, the original password remains hidden. + +#### Authentication Token + +An authentication token is a computer-generated code that securely transmits information about user identities between applications and websites. It allows users to access services without having to enter their login credentials each time. The user logs in once, and a unique token is generated and shared with connected applications or websites to verify their identity. + +An authentication token is formed of three key components: the header (defines the token type and the signing algorithm), the payload (provides information about the user and other metadata), and the signature (verifies the authenticity of a message). + +To generate the tokens, we use the open standard JSON Web Tokens (JWT). The token’s payload has 3 parts: sub, iat, and exp. + +- sub: The ‘sub’ claim in a JWT stands for ‘subject’. It identifies the subject of the JWT, which is typically the user or the entity that the token represents. It’s a way to identify who the token is about. + +- iat: The ‘iat’ claim stands for ‘Issued At’. It identifies the time at which the JWT was issued. The value must be a NumericDate, which is defined as the number of seconds (not milliseconds) since Epoch (1970-01-01T00:00:00Z UTC). + +- exp: The ‘exp’ claim stands for ‘Expiration Time’. It identifies the expiration time on or after which the JWT must not be accepted for processing. Like ‘iat’, the value must be a NumericDate. -_README.md created by [Si6gma](https://github.com/Si6gma)_ diff --git a/ascii_art.txt b/ascii_art.txt deleted file mode 100644 index d8badd1..0000000 --- a/ascii_art.txt +++ /dev/null @@ -1,40 +0,0 @@ - - ..... . .. - %*---%::% @- - .:::::::::----==++**##+. %. .% *= .+% - :#@@@@@@@@@@@@@@@@%%%%%@@+ :@+++*- -+==#= - =@@*------*@@*:.... =@@+ =* .%. %. - +@@= +@@- =@@+ *: +%+++*= - *@@- .*@%- =@@+ - .*@%- .#@%=::---===*@@+ - .#@%: .#@@@@@@@@@@@@@#- -=- :-. .+##**#########%#= - :%@#: -@@*. -%@+ .#@@@###%@@@#***+- - -%@#. +@@= -@@+ .:::*@@+...-%@#. - =@@+ .::----====-. .*@%- =@@+.-#@@@@@@@@@%%%@@@#****+=++-. - +@@= :*@@@@@@@@@@@@%-:#@#: =@@+.*@@+:=%@@*=++#@@@%%@@@@@@%#: - +@@= =@@*::......=@@+-%@#. =@@+.*@%- .#@%: =@@+ .*@@=... - .#@%: =@@= :%@#=%@#. =@@+.*@%- :%@#. =@@= +@@= - :%@#. =@@= .#@%+@@*. =@@+:#@%- -@@*. +@@= .*@@- - -@@*. ..:*@@#++*****##@@#*@@#+===+++****%@@=:*@@%#%@@@####%@%: .*@#: - =@@%##%%%@@@@@@@%%%######*=::*%%%@@@%%%%%%%%#+. .=***####*####+: .*@#: - - - - ...... . .. - -======+++**##%+ %*---%::% @- - +@#+++*@@+=--:-%%. %. .% *= .+% - *@- :@# .%%. :@+++*- -+==#= - #@: -@#..:::-%%. =* .%. %. - .%%. :%%%%#####= :*- =- :%%###%%%%%+ *: +%+++*= - .@% *@- :@# ..-@@=:-@%:.. - -@* .=++++***:. %@: :@# .#@%%@@%%%@@###***= - =@+ .%@+===-=%%- %%. -@* :@# -@* -@#:*@*--. - +@= .%%. *@+ @# :@# :@# =@+ -@* =@+ - #@: .:%%======#@* @#-----==*@* -@%+#@#+=#@= +@= -.#@%%%%%%##*+++++=:. =******+++=. :===+==++- +@= - - - - - - diff --git a/i18n.py b/i18n.py index 1eb7c76..a928adc 100644 --- a/i18n.py +++ b/i18n.py @@ -1,44 +1,77 @@ -class firstrun(): - prompt1 = "Could not determine server's " +class firstrun: + setting_not_found = "Could not determine server's {0}" empty_config = "Server configuration is empty. Deleting..." - prompt2 = "Is this the first time you are running the server?" + ft_question = "Is this the first time you are running the server?" config_not_found = "Configuration file not found!" exec = "Server will now run its configuration process" - fix_missing = "Please enter the Server's" - welcome_message = "Welcome to PesuPy Chat Server Software!" + fix_missing = "Please enter the Server's {0}" + welcome_message = "Welcome to the Account System Demonstration Backend" setup_server_dir = "Please enter the path to a folder where the server can store its files" keypair_setup = "Setting up Server Keypair..." initialize_db = "Setting up Databases for use..." security = "For security reasons, enter server launch password again." exit = "Server will now exit. Please run it again!" - class savedata(): - gui = "Opening file chooser dialog..." - nogui = "Cannot open file chooser! Enter the path manually:" - error = "An error occurred" - write_error = "An error occurred while trying to write server files.\nPlease choose another path" - not_a_dir = "Please enter path to a folder!" - creating = "Folder not accessible." - input_writable = "Please enter a writeable folder path" - created = "Written Server files successfully." - created_new = "Created Server folder successfully." - data_exists = "Previous Installation Detected! Please delete the files or choose another folder." - class database(): - host = 'Enter MySQL/MariaDB Server IP Address: ' - port = 'Enter MySQL/MariaDB Server Port (leave blank for 3306): ' - user = 'Enter Username of user with CREATE privilege: ' - passwd = 'Enter Password of the user: ' - - class passwd(): - explain = "\ - \ - " - input = "Enter the server's launch password: " - confirm = "Enter it again to confirm: " - retry = "Passwords do not match!" - -class log(): - class tags(): + listenaddr = "Enter Server Listen Address: " + listenport = "Enter Server Listen Port: " + + +class savedata: + gui = "Opening file chooser dialog..." + nogui = "Cannot open file chooser! Enter the path manually:" + error = "An error occurred" + write_error = "An error occurred while trying to write server files.\nPlease choose another path" + not_a_dir = "Please enter path to a folder!" + creating = "Folder not accessible." + input_writable = "Please enter a writeable folder path" + created = "Written Server files successfully." + created_new = "Created Server folder successfully." + data_exists = "Previous Installation Detected! Please delete the files or choose another folder." + + +class database: + host = 'Enter MySQL/MariaDB Server IP Address: ' + port = 'Enter MySQL/MariaDB Server Port (leave blank for 3306): ' + user = 'Enter Username of user with CREATE privilege: ' + passwd = 'Enter Password of the user: ' + creds_not_found = "Could not find database credentials. Rerunning server configuration process" + de_cred_fail = "Error while decrypting database credentials. Check your password\n{}" + + +class password: + explain = "The server's 'Launch Password' is used to encrypt credentials.\n\ +The server will not launch without it." + input = "Enter the server's launch password: " + confirm = "Enter it again to confirm: " + retry = "Passwords do not match!" + + +class log: + class tags: info = '[INFO] ' warn = '[WARN] ' error = '[ERR] ' - server_start = "Server starting from path {0}...." \ No newline at end of file + debug = '[DEBUG] ' + + class conn: + attempt = "Remote {0} attempted connection" + init = "Remote {0} initiated connection with UUID: {1}" + disconnected = "Client {0} disconnected due to:\n\t{1}" + db_conn_success = "Connected to database {0}:{1}" + db_conn_err = "Could not connect to database: {}" + + class db: + init_success = 'Created schemas successfully' + init_fail = 'Failed to create schemas: {}' + + class packet: + pubkey_recv = "Received public key for {}" + signup_attempt = "Client {0} attempted SIGNUP with username '{1}'" + signup_success = "Account creation successful for '{}'." + token_gen = "Generated token for {}" + login_success = "User {0} logged in from {1}" + logout_success = "User {0} logged out from {1}" + captcha_gen = "Generated CAPTCHA for client {0} with code = {1}" + acc_delete = "Account deletion successful for '{}'." + server_start = "Server starting from path {0}...." + server_online = "Server Online!" + server_exit = "Goodbye!" diff --git a/requirements.txt b/requirements.txt index 630bcae..0ba7210 100644 Binary files a/requirements.txt and b/requirements.txt differ diff --git a/server_main.py b/server_main.py index 072f30b..229f9f9 100644 --- a/server_main.py +++ b/server_main.py @@ -1,4 +1,5 @@ -import os, sys +import os +import sys import asyncio import websockets from server_modules import firstrun @@ -13,7 +14,7 @@ def execute_firstrun(): firstrun.main() print(i18n.firstrun.security) - db.decrypt_creds(en.fermat_gen(firstrun.working_dir.workingdir), firstrun.working_dir.workingdir) + db.decrypt_creds(en.fernet_gen(firstrun.working_dir.workingdir), firstrun.working_dir.workingdir) print(i18n.firstrun.initialize_db) db.initialize_schemas() db.close() @@ -21,38 +22,60 @@ def execute_firstrun(): sys.exit() -def check_missing_config(f, yaml, config): - try: - if yaml[config] is None: - print(i18n.firstrun.prompt1 + config) - if config == 'working_directory': - print(i18n.firstrun.prompt2) - while True: - choice = input("(Y / N) > ") - if choice.lower() == 'y': - print(i18n.firstrun.exec) - f.close() - os.remove(f'{rootdir}/config.yml') - firstrun.main() - print(i18n.firstrun.exit) - sys.exit() - elif choice.lower() == 'n': - fill_missing_config(f, yaml, 'working_directory') - break - else: - fill_missing_config(f, yaml, config) - except KeyError: - print(i18n.firstrun.prompt1 + config) - fill_missing_config(f, yaml, config) - - -def fill_missing_config(f, yaml, config): - print(i18n.firstrun.fix_missing, config) - yaml[config] = input('\n> ') - if config in ['listen_port', 'any_other_int_type_config']: - yaml[config] = int(yaml[config]) +# def check_missing_config(f, yamlc, config): +# try: +# if yamlc[config] is None: +# print(i18n.firstrun.setting_not_found.format(config)) +# if config == 'working_directory': +# print(i18n.firstrun.ft_question) +# while True: +# choice = input("(Y / N) > ") +# if choice.lower() == 'y': +# print(i18n.firstrun.exec) +# f.close() +# os.remove(f'{rootdir}/config.yml') +# firstrun.main() +# print(i18n.firstrun.exit) +# sys.exit() +# elif choice.lower() == 'n': +# fill_missing_config(f, yamlc, 'working_directory') +# break +# else: +# fill_missing_config(f, yamlc, config) +# except KeyError: +# print(i18n.firstrun.setting_not_found.format(config)) +# fill_missing_config(f, yamlc, config) + +def check_missing_config(file, yaml_config, config_key): + if yaml_config.get(config_key) is not None: + return + print(i18n.firstrun.setting_not_found.format(config_key)) + + if config_key != 'working_directory': + fill_missing_config(file, yaml_config, config_key) + return + print(i18n.firstrun.ft_question) + + while True: + choice = input("(Y / N) > ").lower() + if choice == 'y': + print(i18n.firstrun.exec) + file.close() + os.remove(f'{rootdir}/config.yml') + firstrun.main() + print(i18n.firstrun.exit) + sys.exit() + elif choice == 'n': + fill_missing_config(file, yaml_config, 'working_directory') + break + +def fill_missing_config(f, yamlc, config): + print(i18n.firstrun.fix_missing.format(config)) + yamlc[config] = input('\n> ') + if config in ('listen_port', 'any_other_int_type_config'): + yamlc[config] = int(yamlc[config]) f.seek(0) - f.write(dumpyaml(yaml)) + f.write(dumpyaml(yamlc)) async def catch(websocket): @@ -60,23 +83,23 @@ async def catch(websocket): try: while True: result = await p.handle(SESSIONS, SERVER_CREDS, await websocket.recv(), websocket) - if result in ('CONN_CLOSED', 'USER_PACKET'): + if result in ('CONN_CLOSED',): pass - else: + elif result: await websocket.send(result) # Handle disconnection due to any exception except Exception as err3: client = await p.identify_client(websocket, SESSIONS) - print(f"[INFO] CLIENT {client} DISCONNECTED due to\n\t", err3) + print(i18n.log.tags.info + i18n.log.conn.disconnected.format(client, err3)) del SESSIONS[client] return None -async def main(host, port): +async def main(chost, cport): async with websockets.serve( - catch, host=host, port=port, - ping_interval=30, ping_timeout=None, close_timeout=None, - max_size=10485760 + catch, host=chost, port=cport, + ping_interval=120, ping_timeout=None, close_timeout=None, + max_size=1048576 ): await asyncio.Future() # run forever @@ -87,7 +110,7 @@ async def main(host, port): try: rootdir = os.path.dirname(os.path.abspath(__file__)) - print(i18n.log.tags.info + i18n.log.server_start.format(rootdir)) + print('\n' + i18n.log.tags.info + i18n.log.server_start.format(rootdir)) f = open(f'{rootdir}/config.yml', 'r+') yaml = loadyaml(f.read()) if not yaml: @@ -99,70 +122,34 @@ async def main(host, port): check_missing_config(f, yaml, 'listen_address') check_missing_config(f, yaml, 'listen_port') if not os.path.isfile(f"{yaml['working_directory']}/creds/db"): - raise TypeError("DB_CREDS_NOT_FOUND") + print(i18n.database.creds_not_found) + execute_firstrun() f.close() except FileNotFoundError as err: print(i18n.firstrun.config_not_found, i18n.firstrun.exec) execute_firstrun() - except TypeError: - print("Could not find database credentials. Server will now run its configuration process again") - execute_firstrun() workingdir = yaml['working_directory'] host, port = yaml['listen_address'], yaml['listen_port'] try: - fkey = en.fermat_gen(workingdir) + fkey = en.fernet_gen(workingdir) db.decrypt_creds(fkey, workingdir) except Exception as w: - print("Error while decrypting database credentials. Check your password\n", w) + print(i18n.database.de_cred_fail.format(w)) print(i18n.firstrun.exit) sys.exit() - try: - with open(f'{workingdir}/creds/queue_publickey', 'rb') as f: - pem_pubkey = f.read() - pubkey = en.deser_pem(pem_pubkey, 'public') - except FileNotFoundError: - print("Could not find packet queue public key. Server will now generate it from the private key.") - try: - with open(f'{workingdir}/creds/queue_privatekey', 'rb') as f: - en_pem_prkey = f.read() - pem_prkey = fkey.decrypt(en_pem_prkey) - prkey = en.deser_pem(pem_prkey, 'private') - pubkey = prkey.public_key() - - with open(f'{workingdir}/creds/queue_publickey', 'wb') as f: - f.write(pem_pubkey) - except FileNotFoundError: - print("Could not find packet queue keypair. Server will now generate it again.") - ch = input("This will cause previously unsent packets in the queue to be lost. Continue? (y/n) > ") - if ch.lower() == 'y': - db.clear_queue(user=None) - firstrun.save_queue_keypair(fkey, workingdir) - print(i18n.firstrun.exit) - sys.exit() - if ch.lower() == 'n': - print("Key ah kaanume enna panradhu ippo?") - exit() - else: - with open(f'{workingdir}/creds/queue_privatekey', 'rb') as f: - en_pem_prkey = f.read() - pem_prkey = fkey.decrypt(en_pem_prkey) - prkey = en.deser_pem(pem_prkey, 'private') - server_eprkey, server_epbkey = en.create_rsa_key_pair() SERVER_CREDS['server_eprkey'] = server_eprkey SERVER_CREDS['server_epbkey'] = en.ser_key_pem(server_epbkey, 'public') - SERVER_CREDS['queue_privkey'] = prkey - SERVER_CREDS['queue_pubkey'] = pubkey - print("[INFO] SERVER ONLINE!") + print('\n' + i18n.log.tags.info + i18n.log.server_online) try: asyncio.run(main(host, port)) except KeyboardInterrupt: - print('\n[INFO] Goodbye!') + print('\n' + i18n.log.tags.info + i18n.log.server_exit) db.close() sys.exit() diff --git a/server_modules/db_handler.py b/server_modules/db_handler.py index 284a6fa..42dc25a 100644 --- a/server_modules/db_handler.py +++ b/server_modules/db_handler.py @@ -2,14 +2,11 @@ from uuid import uuid4 import pickle from . import encryption as en +from i18n import log - - -initialize_ddl = """DROP SCHEMA IF EXISTS chatapp_accounts; -DROP SCHEMA IF EXISTS chatapp_chats; -DROP SCHEMA IF EXISTS chatapp_internal; -CREATE DATABASE IF NOT EXISTS chatapp_accounts; -CREATE TABLE IF NOT EXISTS chatapp_accounts.users ( +initialize_ddl = """DROP SCHEMA IF EXISTS pfyt_accounts; +CREATE DATABASE IF NOT EXISTS pfyt_accounts; +CREATE TABLE IF NOT EXISTS pfyt_accounts.users ( UUID char(36) PRIMARY KEY, USERNAME varchar(32) UNIQUE NOT NULL, FULL_NAME varchar(80) NOT NULL, @@ -17,252 +14,123 @@ EMAIL varchar(32) DEFAULT NULL, CREATION timestamp NOT NULL DEFAULT CURRENT_TIMESTAMP ); -CREATE TABLE IF NOT EXISTS chatapp_accounts.auth ( - UUID char(36) NOT NULL REFERENCES chatapp_accounts.users(UUID) ON DELETE CASCADE ON UPDATE CASCADE, +CREATE TABLE IF NOT EXISTS pfyt_accounts.auth ( + UUID char(36) NOT NULL REFERENCES pfyt_accounts.users(UUID) ON DELETE CASCADE, SALTED_HASHBROWN blob NOT NULL, TOKEN_SECRET tinytext ); -CREATE TABLE IF NOT EXISTS chatapp_accounts.pubkeys ( - UUID char(36) REFERENCES chatapp_accounts.users(UUID) ON DELETE CASCADE ON UPDATE CASCADE, - PUBKEY blob NOT NULL -); -CREATE DATABASE IF NOT EXISTS chatapp_chats; -CREATE TABLE IF NOT EXISTS chatapp_chats.rooms ( - ID int PRIMARY KEY AUTO_INCREMENT, - CREATOR_UUID char(36) NOT NULL REFERENCES chatapp_accounts.users (UUID), - ROOM_TYPE int NOT NULL, - MEMBERS blob NOT NULL, - CHAT_TABLE tinytext NOT NULL -); -CREATE DATABASE IF NOT EXISTS chatapp_internal; -CREATE TABLE IF NOT EXISTS chatapp_internal.settings (PARAM varchar(64) NOT NULL, VALUE varchar(256) NOT NULL); -CREATE TABLE IF NOT EXISTS chatapp_internal.`message_queue` ( - `timestamp` timestamp PRIMARY KEY DEFAULT CURRENT_TIMESTAMP, - `recipientUUID` char(36) NOT NULL, - `packet` mediumblob NOT NULL -);""" +""" -createroom = """CREATE TABLE chatapp_chats.{0} ( - `messageID` int NOT NULL AUTO_INCREMENT, - `messageUUID` char(36) UNIQUE NOT NULL, - `sender` char(36) NOT NULL, - `timestamp` timestamp NOT NULL DEFAULT CURRENT_TIMESTAMP, - `message` mediumblob NOT NULL, - `type` varchar(16) NOT NULL, - `pinned` bool NOT NULL DEFAULT false, - PRIMARY KEY (`messageID`) -);""" -queries = {'initialize': initialize_ddl, 'create_room':createroom} -fields_to_check = { - 'username':{'table':'chatapp_accounts.users','attribute':'USERNAME'}, - 'room':{'table':'chatapp_chats.rooms', 'attribute':'CHAT_TABLE'} - } class db: con = None cur = None + def decrypt_creds(fkey, workingdir): with open(f'{workingdir}/creds/db', 'rb') as f: data = f.read() decrypted = fkey.decrypt(data) dict = pickle.loads(decrypted) try: - setattr(db, 'con', sqltor.connect(host = dict['host'], user = dict['user'], passwd = dict['passwd'])) + setattr(db, 'con', sqltor.connect(host=dict['host'], user=dict['user'], passwd=dict['passwd'])) setattr(db, 'cur', db.con.cursor()) if db.con.is_connected(): - print('[INFO] Connected to database ',dict['host'],':',dict['port'],sep='') + print(log.tags.info + log.conn.db_conn_success.format(dict['host'], dict['port'])) + db.cur.execute("USE pfyt_accounts") except sqltor.errors.ProgrammingError as errrr: - print('[ERROR] Could not connect to database:', errrr) + print(log.tags.error + log.conn.db_conn_err.format(errrr)) + def initialize_schemas(): try: - query = queries['initialize'].rstrip(';').split(';\n') + query = initialize_ddl.rstrip(';').split(';\n') for i in query: - print('[DEBUG]',i) # print('[INFO] Created schemas successfully') db.cur.execute(i) db.con.commit() - print('[DEBUG] OK') + print(log.tags.info + log.db.init_success) except Exception as error: - print('[ERROR] Failed to create schemas:', error) + print(log.tags.error + log.db.init_fail.format(error)) + def check_if_exists(value, field): - col = fields_to_check[field]['attribute'] - table = fields_to_check[field]['table'] - db.cur.execute(f"select {col} from {table} where {col} = '{value}'") - data = db.cur.fetchall() + db.cur.execute(f"SELECT USERNAME FROM pfyt_accounts.users WHERE USERNAME = %s", (value,)) + data = db.cur.fetchall() try: if data[0][0] == value: return True except IndexError: return False - -def get_uuid(identifier): - try: - if '@' not in identifier: - db.cur.execute("SELECT UUID FROM chatapp_accounts.users WHERE USERNAME=%s", (identifier,)) - uuid = db.cur.fetchall()[0][0] - elif '@' in identifier: - db.cur.execute("SELECT UUID FROM chatapp_accounts.users WHERE EMAIL=%s", (identifier,)) - uuid = db.cur.fetchall()[0][0] - except IndexError: - return 'ACCOUNT_DNE' - return uuid - -def queue_packet(user_uuid, de_packet, SERVER_CREDS): - en_packet = en.encrypt_packet(de_packet, SERVER_CREDS['queue_pubkey']) - db.cur.execute("INSERT INTO chatapp_internal.message_queue(recipientUUID, packet) VALUES (%s, %s)", (user_uuid, en_packet)) - db.con.commit() - -def clear_queue(user: str | None): - if user: - db.cur.execute("DELETE FROM chatapp_internal.message_queue WHERE recipientUUID = %s", (user,)) - db.con.commit() - elif not user: - db.cur.execute("DELETE FROM chatapp_internal.message_queue WHERE packet IS NOT NULL") - db.con.commit() - -def flush_queue(user_uuid): - db.cur.execute("SELECT packet FROM chatapp_internal.message_queue WHERE recipientUUID = %s", (user_uuid,)) - return db.cur.fetchall() + else: + return False def close(): if db.con is not None: db.con.close() -class Config(db): - def update(setting, config): - db.cur.execute("INSERT INTO chatapp_internal.settings VALUES (%s, %s)", (setting, config)) - db.con.commit() - - def read(setting): - db.cur.execute("SELECT VALUE FROM chatapp_internal.settings WHERE PARAM = %s", (setting,)) - return db.cur.fetchall()[0][0] + class Account(db): def create(username, fullname, dob, email, salted_pwd): # UUID, USERNAME, FULL_NAME, DOB, EMAIL, CREATION uuid = str(uuid4()) - query = "INSERT INTO chatapp_accounts.users(UUID, USERNAME, FULL_NAME, DOB, EMAIL) VALUES (%s, %s, %s, %s, %s)" - print(f"[DEBUG | for {uuid}]",query) + query = "INSERT INTO pfyt_accounts.users(UUID, USERNAME, FULL_NAME, DOB, EMAIL) VALUES (%s, %s, %s, %s, %s)" + print(f"[DEBUG | for {uuid}]", query) db.cur.execute(query, (uuid, username, fullname, dob, email)) db.con.commit() - pwd_query = "INSERT INTO chatapp_accounts.auth(UUID, SALTED_HASHBROWN) VALUES (%s, %s)" - print(f"[DEBUG | for {uuid}]", pwd_query) + pwd_query = "INSERT INTO pfyt_accounts.auth(UUID, SALTED_HASHBROWN) VALUES (%s, %s)" db.cur.execute(pwd_query, (uuid, salted_pwd)) db.con.commit() - def check_pubkey(uuid): - db.cur.execute(f"SELECT PUBKEY FROM chatapp_accounts.pubkeys WHERE UUID = '{uuid}'") - data = db.cur.fetchall() - if len(data) == 0: - return False - elif len(data) == 1: - return True - def set_pubkey(user, key): - db.cur.execute("INSERT INTO chatapp_accounts.pubkeys VALUES(%s, %s)", (user, key)) - db.con.commit() - def get_pubkey(user): - db.cur.execute(f"SELECT PUBKEY FROM chatapp_accounts.pubkeys WHERE UUID = '{user}'") - data = db.cur.fetchall() - return data[0][0] - - def check_pwd(pwd, identifier): + def get_uuid(identifier): try: if '@' not in identifier: - db.cur.execute("SELECT UUID FROM chatapp_accounts.users WHERE USERNAME=%s", (identifier,)) + db.cur.execute("SELECT UUID FROM pfyt_accounts.users WHERE USERNAME=%s", (identifier,)) uuid = db.cur.fetchall()[0][0] elif '@' in identifier: - db.cur.execute("SELECT UUID FROM chatapp_accounts.users WHERE EMAIL=%s", (identifier,)) + db.cur.execute("SELECT UUID FROM pfyt_accounts.users WHERE EMAIL=%s", (identifier,)) uuid = db.cur.fetchall()[0][0] except IndexError: return 'ACCOUNT_DNE' - db.cur.execute("SELECT SALTED_HASHBROWN FROM chatapp_accounts.auth WHERE UUID = %s", (uuid,)) + return uuid + + def check_pwd(pwd, identifier): + uuid = Account.get_uuid(identifier) + if uuid == 'ACCOUNT_DNE': + return 'ACCOUNT_DNE' + db.cur.execute("SELECT SALTED_HASHBROWN FROM pfyt_accounts.auth WHERE UUID = %s", (uuid,)) saltedpwd = db.cur.fetchall()[0][0] flag = en.db_check_pwd(pwd, saltedpwd) - return (flag, uuid) + return flag, uuid def set_token(uuid, secret): - db.cur.execute("UPDATE chatapp_accounts.auth SET TOKEN_SECRET = %s WHERE UUID = %s", (secret, uuid)) + db.cur.execute("UPDATE pfyt_accounts.auth SET TOKEN_SECRET = %s WHERE UUID = %s", (secret, uuid)) db.con.commit() + def get_token_key(uuid): - db.cur.execute("SELECT TOKEN_SECRET FROM chatapp_accounts.auth WHERE UUID = %s", (uuid,)) - return db.cur.fetchall()[0][0] - -class Room(db): - def create(creator, data): - if data['room_type'] == 0 and len(data['members']) == 0 or len(data['members']) > 1: - return 'MKROOM_ERROR' - elif len(data['members']) > 1: - return 'NOT_IMPLEMENTED' - # room_type 1 is for group, 2 for broadcast - chat_table = str(uuid4()).replace('-', '') - members_db, members_dne = [creator], [] - for user in data['members']: - flag = check_if_exists(user, 'username') - if flag == True: - members_db.append(get_uuid(user)) - elif flag == False: - members_dne.append(user) - members_insert = pickle.dumps(members_db) + db.cur.execute("SELECT TOKEN_SECRET FROM pfyt_accounts.auth WHERE UUID = %s", (uuid,)) try: - db.cur.execute(queries['create_room'].format(chat_table)) - query = "INSERT INTO chatapp_chats.rooms(CREATOR_UUID, ROOM_TYPE, ROOM_NAME, MEMBERS, CHAT_TABLE) VALUES (%s, %s, %s, %s, %s)" - db.cur.execute(query, (data['members'][0], data['room-type'], data['room-name'], members_insert, chat_table)) - db.con.commit() - # below line is type0 specific, error handling not implemented yet - chat_pubkey = Account.get_pubkey(members_db[1]) - match data['room-type']: - case 0: - return ['MKROOM_OK', data['room-type'], data['room-name'], chat_table, members_db, members_dne, chat_pubkey] - - except: - return 'MKROOM_ERROR' - - def fetch_info(room_uuid): - """[intid, creator_uuid, room_type, members, chat_table]""" - db.cur.execute("SELECT * FROM chatapp_chats.rooms WHERE CHAT_TABLE = %s", (room_uuid,)) - data = db.con.fetchall() - if data == []: - return 'ROOM_DNE' - return data[0] + resp = db.cur.fetchall()[0][0] + if resp: + return resp + else: + return 'TOKEN_NOT_FOUND' + except IndexError: + return 'TOKEN_NOT_FOUND' -class Chat(db): - def save_msg(sender, data): - """ - data = room, action, actiondata(send/edit/delete/pin formats)""" - room_uuid, action, actiondata = data['room'], data['action'], data['actiondata'] - if actiondata['format'] not in ['TEXT']: # add more message formats here - return 'FORMAT_ERR' - match action: - case 'send': - db.cur.execute(f"INSERT INTO chatapp_chats.{room_uuid}(messageUUID, sender, message, type) VALUES (%s, %s, %s, %s)", (str(uuid4()), sender, actiondata['content'], actiondata['format'])) - db.con.commit() - return 'SUCCESS' - case 'edit': - msg = actiondata['msg'] - db.cur.execute(f"SELECT sender FROM chatapp_chats.{room_uuid} WHERE messageUUID = %s", (msg,)) - o_sender = db.cur.fetchall()[0][0] - if sender == o_sender: - db.cur.execute(f"UPDATE chatapp_chats.{room_uuid} SET message = %s, type = %s WHERE messageUUID = %s", (actiondata['content'], actiondata['format'], actiondata['msg'])) - db.con.commit() - return 'SUCCESS' - else: - return 'NOT_YOURS' - case 'delete': - msg = data['actiondata']['msg'] - db.cur.execute(f"SELECT sender FROM chatapp_chats.{room_uuid} WHERE messageUUID = %s", (msg,)) - o_sender = db.cur.fetchall()[0][0] - if sender == o_sender: - db.cur.execute(f"UPDATE chatapp_chats.{room_uuid} SET message = 'DELETED', type = 'sig' WHERE messageUUID = %s", (msg,)) - db.con.commit() - return 'SUCCESS' - else: - return 'NOT_YOURS' - case 'pinned': - db.cur.execute(f"UPDATE chatapp_chats.{room_uuid} SET pinned = true WHERE messageUUID = %s", (msg,)) - db.con.commit() - return 'SUCCESS' - def fetch_history(room, fromtime, totime): - db.cur.execute(f"SELECT * FROM chatapp_chats.{room} WHERE timestamp BETWEEN %s AND %s", (fromtime, totime)) - return db.cur.fetchall() + def logout(uuid): + try: + db.cur.execute("UPDATE pfyt_accounts.auth SET TOKEN_SECRET = NULL WHERE UUID = %s", (uuid,)) + db.con.commit() + return 'SUCCESS' + except Exception as err: + print(log.tags.error + "LOGOUT_DB_ERROR:", err) + return 'FAILURE' + def delete(uuid): + try: + db.cur.execute("DELETE FROM pfyt_accounts.users WHERE UUID = %s", (uuid,)) + db.con.commit() + return 'SUCCESS' + except Exception as err: + print(log.tags.error + "ACC_DELETE_DB_ERROR:", err) + return 'FAILURE' \ No newline at end of file diff --git a/server_modules/encryption.py b/server_modules/encryption.py index 91a816c..e81b35f 100644 --- a/server_modules/encryption.py +++ b/server_modules/encryption.py @@ -1,11 +1,10 @@ -from cryptography.hazmat.primitives import serialization, hashes, hmac +from cryptography.hazmat.primitives import serialization, hashes from cryptography.hazmat.backends import default_backend from cryptography.hazmat.primitives.asymmetric import rsa, padding from cryptography.hazmat.primitives import padding as spadding from cryptography.fernet import Fernet from cryptography.hazmat.primitives.kdf.pbkdf2 import PBKDF2HMAC from cryptography.hazmat.primitives.ciphers import Cipher, algorithms, modes -from cryptography.exceptions import InvalidSignature from secrets import token_hex from getpass import getpass from os import urandom @@ -15,6 +14,7 @@ import jwt import datetime + def create_rsa_key_pair(): # Generate a 2048-bit RSA private key private_key = rsa.generate_private_key( @@ -25,15 +25,17 @@ def create_rsa_key_pair(): public_key = private_key.public_key() return private_key, public_key + def fernet_initkey(workingdir): - passwd = '' while True: - passwd = getpass(i18n.firstrun.passwd.input) - confirm = getpass(i18n.firstrun.passwd.confirm) + print(i18n.password.explain) + passwd = getpass(i18n.password.input) + confirm = getpass(i18n.password.confirm) if passwd == confirm: break else: - print(i18n.firstrun.passwd.retry) + print(i18n.password.retry) + # Generate a Fernet key with the password and save the salt salt = urandom(16) with open(f"{workingdir}/creds/salt", "wb") as f: @@ -48,7 +50,8 @@ def fernet_initkey(workingdir): key = Fernet(key) return key -def fermat_gen(workingdir): + +def fernet_gen(workingdir): passwd = getpass("Enter Password: ") with open(f"{workingdir}/creds/salt", "rb") as f: salt = f.read() @@ -62,6 +65,7 @@ def fermat_gen(workingdir): key = Fernet(key) return key + def ser_key_pem(key, type: str): if type == 'public': return key.public_bytes( @@ -74,12 +78,15 @@ def ser_key_pem(key, type: str): format=serialization.PrivateFormat.PKCS8, encryption_algorithm=serialization.NoEncryption() ) + + def deser_pem(key, type): if type == 'public': return serialization.load_pem_public_key(key) elif type == 'private': return serialization.load_pem_private_key(key, password=None) + def encrypt_packet(data, pubkey): data = pickle.dumps(data) # Generate symmetric key and encrypt it @@ -92,17 +99,21 @@ def encrypt_packet(data, pubkey): label=None, ), ) + # Encrypt packet data with symmetric key cbc = urandom(16) + # Add the padding to the data padder = spadding.PKCS7(algorithms.AES.block_size).padder() padded_data = padder.update(data) + padder.finalize() + # Encrypting the padded_data cipher = Cipher(algorithms.AES(skey), modes.CBC(cbc)) encryptor = cipher.encryptor() ciphertext = encryptor.update(padded_data) + encryptor.finalize() - return pickle.dumps({'skey':encrypted_skey, 'cbc':cbc, 'ciphertext':ciphertext}) + return pickle.dumps({'skey': encrypted_skey, 'cbc': cbc, 'ciphertext': ciphertext}) + def decrypt_packet(encrypted_data, privkey): try: @@ -128,8 +139,9 @@ def decrypt_packet(encrypted_data, privkey): data = unpadder.update(decrypted_data) + unpadder.finalize() return pickle.loads(data) except Exception as error: - return {'type':'decrypt_error','data':f'{error}'} - + return {'type': 'decrypt_error', 'data': f'{error}'} + + def salt_pwd(password): pwd = password.encode() salt = urandom(16) @@ -145,11 +157,11 @@ def salt_pwd(password): key = urlsafe_b64encode(kdf.derive(pwd)) # Store the salt and key in your database - return pickle.dumps({'salt':salt, 'key':key}) + return pickle.dumps({'salt': salt, 'key': key}) + def db_check_pwd(pwd, saltedpwd): salted_pwd = pickle.loads(saltedpwd) - print(f"[DEBUG] {salted_pwd}") salt, key = salted_pwd['salt'], salted_pwd['key'] password = pwd.encode() kdf = PBKDF2HMAC( @@ -166,7 +178,8 @@ def db_check_pwd(pwd, saltedpwd): return True else: return False - + + def gen_token(user, validity): """ `validity` in days @@ -174,18 +187,19 @@ def gen_token(user, validity): secret = token_hex(32) payload = { "sub": user, - "iat": datetime.datetime.utcnow(), - "exp": datetime.datetime.utcnow() + datetime.timedelta(days = validity) + "iat": datetime.datetime.now(datetime.UTC), + "exp": datetime.datetime.now(datetime.UTC) + datetime.timedelta(days=validity) } access_token = jwt.encode(payload, secret, algorithm="HS256") - return (secret, access_token) + return secret, access_token + def validate_token(key, token, user): try: decoded_token = jwt.decode(token, key, algorithms=["HS256"]) except: return 'TOKEN_INVALID' - timenow = datetime.datetime.utcnow() + timenow = datetime.datetime.utcnow().timestamp() if decoded_token['sub'] == user and timenow < decoded_token['exp']: return 'TOKEN_OK' elif decoded_token['sub'] == user and timenow > decoded_token['exp']: @@ -193,4 +207,4 @@ def validate_token(key, token, user): elif decoded_token['sub'] != user: return 'TOKEN_INVALID' else: - return 'TOKEN_INVALID' \ No newline at end of file + return 'TOKEN_INVALID' diff --git a/server_modules/firstrun.py b/server_modules/firstrun.py index 81a371a..bca06bb 100644 --- a/server_modules/firstrun.py +++ b/server_modules/firstrun.py @@ -1,95 +1,85 @@ import os import getpass -from i18n import firstrun +import i18n from . import encryption as e -import pickle +import pickle from yaml import dump as dumpyaml -try: + +try: from tkinter import filedialog except: pass + class working_dir(): workingdir = '' + def get_server_dir(): - while True: - try: - # Open GUI file picker if possible - print(firstrun.savedata.gui) - spath = filedialog.askdirectory() - except: - print(firstrun.savedata.nogui) - spath = input().rstrip('/\\') - finally: - break - return spath + try: + print(i18n.savedata.gui) + return filedialog.askdirectory() + except: + print(i18n.savedata.nogui) + return input().rstrip('/\\') + + +def create_directory(path): + try: + os.mkdir(path) + print(i18n.savedata.created) + except OSError as err: + print(f"{i18n.savedata.error}:\n{err}") + return False + return True + def setup_server_dir(): while True: - while True: - spath = get_server_dir() - # Do nothing if folder exists - if os.path.exists(spath) and os.path.isdir(spath): + spath = get_server_dir() + if os.path.exists(spath) and os.path.isdir(spath): + pass + elif os.path.exists(spath) and not os.path.isdir(spath): + spath = input(f"{i18n.savedata.not_a_dir}:\n") + elif not os.path.exists(spath): + if not create_directory(spath): + spath = input(f"{i18n.savedata.input_writable}:\n") + continue + + creds_path = f'{spath}/creds' + if not os.path.exists(creds_path): + if create_directory(creds_path): break - # If either the path leads to a file or is not writable (or invalid) - elif os.path.exists(spath) and not os.path.isdir(spath): - spath = input(f"{firstrun.savedata.not_a_dir}:\n") - elif not os.path.exists(spath): - print(firstrun.savedata.creating, end=' ') - try: - os.mkdir(spath) - except OSError as e: - print(f"{firstrun.savedata.error}:\n{e}") - spath = input(f"{firstrun.savedata.input_writable}:\n") - else: - print(firstrun.savedata.created) - break - if not os.path.exists(f'{spath}/creds'): - os.mkdir(f'{spath}/creds') - break - elif os.path.exists(f'{spath}/creds'): - print(firstrun.savedata.data_exists) + else: + print(i18n.savedata.data_exists) return spath -def save_db_credentials(fkey,workingdir): - host = input(firstrun.database.host) - port = input(firstrun.database.port) - user = input(firstrun.database.user) + +def save_db_credentials(fkey, workingdir): + host = input(i18n.database.host) + port = input(i18n.database.port) + user = input(i18n.database.user) if not port: port = 3306 else: port = int(port) - passwd = getpass.getpass(firstrun.database.passwd) - data = pickle.dumps({'host':host, 'port': port, 'user':user, 'passwd':passwd}) + passwd = getpass.getpass(i18n.database.passwd) + data = pickle.dumps({'host': host, 'port': port, 'user': user, 'passwd': passwd}) with open(f'{workingdir}/creds/db', 'wb') as f: f.write(fkey.encrypt(data)) -def save_queue_keypair(fkey, workingdir): - prkey, pubkey = e.create_rsa_key_pair() - pem_prkey, pem_pubkey = e.ser_key_pem(prkey, 'private'), e.ser_key_pem(pubkey, 'public') - en_pem_prkey = fkey.encrypt(pem_prkey) - with open(f'{workingdir}/creds/queue_privatekey', 'wb') as f: - f.write(en_pem_prkey) - with open(f'{workingdir}/creds/queue_publickey', 'wb') as f: - f.write(pem_pubkey) def main(): - print(firstrun.welcome_message) - print(firstrun.setup_server_dir) + print(i18n.firstrun.welcome_message) + print(i18n.firstrun.setup_server_dir) workingdir = setup_server_dir() setattr(working_dir, 'workingdir', workingdir) fkey = e.fernet_initkey(workingdir) save_db_credentials(fkey, workingdir) - save_queue_keypair(fkey, workingdir) del fkey - host = input("Enter Server Listen Address: ") - port = int(input("Enter Server Listen Port: ")) + host = input(i18n.firstrun.listenaddr) + port = int(input(i18n.firstrun.listenport)) with open(f'{os.path.dirname(os.path.abspath(__file__))}/../config.yml', 'w') as fi: config = {'working_directory': workingdir, 'listen_address': host, 'listen_port': port} fi.write(dumpyaml(config)) - - - - diff --git a/server_modules/packet_handler.py b/server_modules/packet_handler.py index 9e209f8..78c6b0b 100644 --- a/server_modules/packet_handler.py +++ b/server_modules/packet_handler.py @@ -7,17 +7,19 @@ from captcha.image import ImageCaptcha from random import randint import datetime +from i18n import log + async def identify_client(websocket, SESSIONS): return list(SESSIONS.keys())[[i[0] for i in list(SESSIONS.values())].index(websocket)] + async def disconnect(ws, code, reason): - print(f"[INFO] CLIENT {ws.remote_address} DISCONNECTED due to",code,reason) + print(log.tags.info + log.conn.disconnected.format(ws.remote_address, code + ' ' + reason)) await ws.close(code=code, reason=reason) return 'CONN_CLOSED' -#async def get_packet(ws, type): -# # packet validator + def parse_date(date_string): date_formats = ["%d/%m/%Y", "%d-%m-%Y", "%Y-%m-%d", "%Y/%m/%d"] @@ -31,6 +33,7 @@ def parse_date(date_string): else: return 'PARSE_ERR' + def parse_time(time_string): try: time_obj = datetime.datetime.strptime(time_string, '%Y-%m-%d %H:%M:%S') @@ -38,54 +41,47 @@ def parse_time(time_string): return 'PARSE_ERR' else: return 'VALID_TIME' - + async def establish_conn(SESSIONS, SERVER_CREDS, ws): - print(f"[INFO] Remote {ws.remote_address} attempted connection") + print(log.tags.info + log.conn.attempt.format(ws.remote_address)) uuid = str(UUID.uuid4()) - SESSIONS[uuid] = [ws, None, None] # ws, public_key, user_uuid + SESSIONS[uuid] = [ws, None, None] # ws, public_key, user_uuid - print(f"[INFO] Remote {ws.remote_address} initiated connection with UUID: {uuid}") - print(f"[INFO] SENDING PUBLIC KEY to {uuid}") + print(log.tags.info + log.conn.init.format(ws.remote_address, uuid)) + print(f"[INFO] Sending public key to {uuid}") # Encrypt Connection - await ws.send(pickle.dumps({'type':'CONN_ENCRYPT_S','data':SERVER_CREDS['server_epbkey']})) + await ws.send(pickle.dumps({'type': 'CONN_ENCRYPT_S', 'data': SERVER_CREDS['server_epbkey']})) try: client_epbkey = pickle.loads(await ws.recv()) try: client_epbkey = s.load_pem_public_key(client_epbkey['data']) - except Exception as e: - print(f"[INFO] CLIENT un-established {ws.remote_address} DISCONNECTED due to INVALID_PACKET") - await ws.close(code = 1008, reason = "Invalid packet structure") + except Exception: + print(log.tags.info + log.conn.disconnected.format(ws.remote_address, "INVALID_PACKET")) + await ws.close(code=1008, reason="Invalid packet structure") return 'CONN_CLOSED' - + SESSIONS[uuid][1] = client_epbkey - print(f"[INFO] Received public key for {ws.remote_address}") + print(log.tags.info + log.packet.pubkey_recv.format(uuid)) del client_epbkey return en.encrypt_packet( - {'type':'STATUS', 'data':{'sig':'CONN_OK'}}, + {'type': 'STATUS', 'data': {'sig': 'CONN_OK'}}, SESSIONS[uuid][1], - ) - # If client sends bullshit instead of its PEM serialized ephemeral public key + ) + # If client sends something else instead of its PEM serialized ephemeral public key except Exception as err2: - print(f"[INFO] CLIENT {uuid} {ws.remote_address} DISCONNECTED due to INVALID_CONN_KEY:\n\t",err2) - await ws.close(code = 1003, reason = "Connection Public Key in invalid format") + print(log.tags.info + log.conn.disconnected.format(ws.remote_address, err2)) + await ws.close(code=1003, reason="Connection Public Key in invalid format") del SESSIONS[uuid] return 'CONN_CLOSED' - + + async def get_resp_packet(SESSIONS, ws, de_packet): uuid = await identify_client(ws, SESSIONS) en_packet = en.encrypt_packet(de_packet, SESSIONS[uuid][1]) return en_packet -async def send_user_packet(SESSIONS, SERVER_CREDS, user_uuid, de_packet): - try: - con_uuid = list(SESSIONS.keys())[[i[2] for i in list(SESSIONS.values())].index(user_uuid)] - ws = SESSIONS[con_uuid][0] - ws.send(en.encrypt_packet(de_packet, SESSIONS[con_uuid[1]])) - except Exception as ear: - print("[DEBUG] Queued Packet for", user_uuid, "due to:\n\t", ear) - db.queue_packet(user_uuid, en.encrypt_packet(de_packet, SERVER_CREDS['queue_pubkey'])) async def signup(SESSIONS, SERVER_CREDS, ws, data): uuid = list(SESSIONS.keys())[[i[0] for i in list(SESSIONS.values())].index(ws)] @@ -96,7 +92,8 @@ async def signup(SESSIONS, SERVER_CREDS, ws, data): dob = parse_date(data['dob']) password = data['password'] except KeyError as ero: - return await get_resp_packet(SESSIONS, ws, {'type':'STATUS','data':{'sig':'SIGNUP_MISSING_CREDS','desc':ero}}) + return await get_resp_packet(SESSIONS, ws, + {'type': 'STATUS', 'data': {'sig': 'MISSING_CREDS', 'desc': ero}}) # Define validation rules validation_rules = [ (len(user) > 32, 'SIGNUP_USERNAME_ABOVE_LIMIT'), @@ -109,19 +106,21 @@ async def signup(SESSIONS, SERVER_CREDS, ws, data): # Validate user input for condition, error_message in validation_rules: if condition: - return await get_resp_packet(SESSIONS, ws, {'type':'STATUS','data':{'sig':error_message}}) + return await get_resp_packet(SESSIONS, ws, {'type': 'STATUS', 'data': {'sig': error_message}}) resp_captcha = await captcha(SESSIONS, SERVER_CREDS, ws, data) - if resp_captcha == True: - print(f"[INFO] CLIENT {uuid} ATTEMPTED SIGNUP WITH username {user}") + if resp_captcha is True: + print(log.tags.info + log.packet.signup_attempt.format(uuid, user)) salted_pwd = en.salt_pwd(password) try: db.Account.create(user, fullname, dob, email, salted_pwd) except Exception as errr: - return await get_resp_packet(SESSIONS, ws, {'type':'STATUS','data':{'sig':'SIGNUP_ERR','desc':errr}}) + return await get_resp_packet(SESSIONS, ws, {'type': 'STATUS', 'data': {'sig': 'SIGNUP_ERR', 'desc': errr}}) else: - return await get_resp_packet(SESSIONS, ws, {'type':'STATUS','data':{'sig':'SIGNUP_OK'}}) - elif resp_captcha == False: - return await get_resp_packet(SESSIONS, ws, {'type':'STATUS','data':{'sig':'CAPTCHA_WRONG'}}) + print(log.tags.info + log.packet.signup_success.format(user)) + return await get_resp_packet(SESSIONS, ws, {'type': 'STATUS', 'data': {'sig': 'SIGNUP_OK'}}) + elif resp_captcha is False: + return await get_resp_packet(SESSIONS, ws, {'type': 'STATUS', 'data': {'sig': 'CAPTCHA_WRONG'}}) + async def login(SESSIONS, SERVER_CREDS, ws, data): try: @@ -129,196 +128,141 @@ async def login(SESSIONS, SERVER_CREDS, ws, data): password = data['password'] dont_ask_again = data['save'] except KeyError as ero: - return await get_resp_packet(SESSIONS, ws, {'type':'STATUS','data':{'sig':'LOGIN_MISSING_CREDS','desc':ero}}) + return await get_resp_packet(SESSIONS, ws, + {'type': 'STATUS', 'data': {'sig': 'MISSING_CREDS', 'desc': ero}}) resp_captcha = await captcha(SESSIONS, SERVER_CREDS, ws, data) - if resp_captcha == True: - flag, uuid = db.Account.check_pwd(password, identifier) - if flag == True: - if dont_ask_again == True: + if resp_captcha is True: + flag = db.Account.check_pwd(password, identifier) + if flag[0] is True: + uuid = flag[1] + if dont_ask_again is True: secret, access_token = en.gen_token(uuid, 30) else: secret, access_token = en.gen_token(uuid, 1) db.Account.set_token(uuid, secret) - en_packet = await get_resp_packet(SESSIONS, ws, {'type':'TOKEN_GEN','data':{'token':access_token}}) - pubkey_resp = await get_pubkey(SESSIONS, SERVER_CREDS, ws, uuid) - if pubkey_resp == 'CONN_CLOSED': - return 'CONN_CLOSED' - elif pubkey_resp == 'CHAT_PUBKEY_OK': - return en_packet - elif flag == False: - return await get_resp_packet(SESSIONS, ws, {'type':'STATUS','data':{'sig':'LOGIN_INCORRECT_PASSWORD'}}) + en_packet = await get_resp_packet(SESSIONS, ws, {'type': 'TOKEN_GEN', 'data': {'token': access_token}}) + print(log.tags.info + log.packet.token_gen.format(uuid)) + return en_packet + elif flag[0] is False: + return await get_resp_packet(SESSIONS, ws, {'type': 'STATUS', 'data': {'sig': 'INCORRECT_PASSWORD'}}) elif flag == 'ACCOUNT_DNE': - return await get_resp_packet(SESSIONS, ws, {'type':'STATUS','data':{'sig':'LOGIN_ACCOUNT_NOT_FOUND'}}) - elif resp_captcha == False: - return await get_resp_packet(SESSIONS, ws, {'type':'STATUS','data':{'sig':'CAPTCHA_WRONG'}}) + return await get_resp_packet(SESSIONS, ws, {'type': 'STATUS', 'data': {'sig': 'ACCOUNT_NOT_FOUND'}}) + elif resp_captcha is False: + return await get_resp_packet(SESSIONS, ws, {'type': 'STATUS', 'data': {'sig': 'CAPTCHA_WRONG'}}) + async def auth(SESSIONS, SERVER_CREDS, ws, data): # REMINDER TO HANDLE LOGIN FROM TWO DEVICES - user = data['data']['user'] - token = data['data']['token'] + user = data['user'] + token = data['token'] con_uuid = await identify_client(ws, SESSIONS) - user_uuid = db.get_uuid(user) + user_uuid = db.Account.get_uuid(user) + if user_uuid == 'ACCOUNT_DNE': + return await get_resp_packet(SESSIONS, ws, {'type': 'STATUS', 'data': {'sig': 'ACCOUNT_NOT_FOUND'}}) key = db.Account.get_token_key(user_uuid) - flag = en.validate_token(key, token, con_uuid) + if key == 'TOKEN_NOT_FOUND': + return await get_resp_packet(SESSIONS, ws, {'type': 'STATUS', 'data': {'sig': 'TOKEN_NOT_FOUND'}}) + flag = en.validate_token(key, token, user_uuid) if flag == 'TOKEN_OK': SESSIONS[con_uuid][2] = user_uuid # tasks to run on login - ws.send(await get_resp_packet(SESSIONS, ws, {'type':'STATUS','data':{'sig':'LOGIN_OK'}})) - print("[INFO] User", user_uuid, "logged in from", ws.remote_address) - packet_queue = db.flush_queue(user_uuid) - for i in packet_queue: - de_packet = en.decrypt_packet(i[0], SERVER_CREDS['queue_privkey']) - if de_packet['type'] == 'decrypt_error': - continue - else: - ws.send(await get_resp_packet(SESSIONS, ws, de_packet)) - db.clear_queue(user=user_uuid) - print("[INFO] Flushed queue for user", user_uuid) - return await get_resp_packet(SESSIONS, ws, {'type':'QUEUE_END'}) + print(log.tags.info + log.packet.login_success.format(user_uuid, ws.remote_address)) + print("[DEBUG] SESSIONS:", SESSIONS) # This line is not meant for production + return await get_resp_packet(SESSIONS, ws, {'type': 'STATUS', 'data': {'sig': 'LOGIN_OK'}}) elif flag == 'TOKEN_EXPIRED': - return await get_resp_packet(SESSIONS, ws, {'type':'STATUS','data':{'sig':'TOKEN_EXPIRED'}}) + return await get_resp_packet(SESSIONS, ws, {'type': 'STATUS', 'data': {'sig': 'TOKEN_EXPIRED'}}) elif flag == 'TOKEN_INVALID': - return await get_resp_packet(SESSIONS, ws, {'type':'STATUS','data':{'sig':'TOKEN_INVALID'}}) - -async def get_pubkey(SESSIONS, SERVER_CREDS, ws, uuid): - flag = db.Account.check_pubkey(uuid) - if not flag: - await ws.send(await get_resp_packet(SESSIONS, ws, {'type':'STATUS','data':{'sig':'CHAT_PUBKEY_MISSING'}})) - while True: - de_pubkey = en.decrypt_packet(await ws.recv(), SERVER_CREDS['server_eprkey']) - print(de_pubkey) - if de_pubkey['type'] == 'CHAT_ENCRYPT_C': - try: - s.load_pem_public_key(de_pubkey['data']['chat_pubkey']) - except Exception as ear: - await ws.send(await get_resp_packet(SESSIONS, ws, {'type':'STATUS','data':{'sig':'CHAT_PUBKEY_INVALID'}})) - else: - db.Account.set_pubkey(uuid, de_pubkey['data']['chat_pubkey']) - await ws.send(await get_resp_packet(SESSIONS, ws, {'type':'STATUS','data':{'sig':'CHAT_PUBKEY_OK'}})) - return 'CHAT_PUBKEY_OK' - else: - print(f"[INFO] CLIENT un-established {ws.remote_address} DISCONNECTED due to INVALID_PACKET") - await ws.close(code = 1008, reason = "Invalid packet structure") - return 'CONN_CLOSED' - elif flag: - return 'CHAT_PUBKEY_OK' - -async def create_room(SESSIONS, SERVER_CREDS, ws, data): - con_uuid = await identify_client(ws, SESSIONS) - if len(data['people']) < 2: - return await get_resp_packet(SESSIONS, ws, {'type':'STATUS','data':{'sig':'MKROOM_INSUFFICIENT_PARTICIPANTS'}}) - else: - flag = db.Room.create(SESSIONS[con_uuid][2], data) - if flag == 'NOT_IMPLEMENTED': - return await get_resp_packet(SESSIONS, ws, {'type':'STATUS','data':{'sig':'MKROOM_NOT_IMPLEMENTED'}}) - elif flag == 'MKROOM_ERROR': - return await get_resp_packet(SESSIONS, ws, {'type':'STATUS','data':{'sig':'MKROOM_ERROR'}}) - elif flag[0] == 'MKROOM_OK': - roomdata = {'room_type':flag[1], 'room_name':flag[2], 'room_uuid':flag[3], 'room_key':flag[6], 'members':flag[4], 'dne':flag[5]} - await broadcast_packet(SESSIONS, SERVER_CREDS, flag[4], {'type':'JOIN_ROOM', 'data':roomdata}) - return await get_resp_packet(SESSIONS, ws, {'type':'ROOM_INFO','data':roomdata}) - -async def broadcast_packet(SESSIONS, SERVER_CREDS, members, packet): - for user in members: - await send_user_packet(SESSIONS, SERVER_CREDS, user, packet) - # send the packet to members - -async def chat_action(SESSIONS, SERVER_CREDS, ws, data): - sender = await identify_client(ws, SESSIONS) - user = SESSIONS[sender][2] - room = db.Room.fetch_info(data['room']) - - if room == 'ROOM_DNE': - return await get_resp_packet(SESSIONS, ws, {'type':'STATUS', 'data':'ROOM_DNE'}) - elif room[1] == user or user in pickle.loads(room[3]): - members = pickle.loads(room[3]) - action_map = { - 'send': 'recv', - 'edit': 'edited', - 'delete': 'deleted', - 'pinned': 'pinned' - } - action = data['action'] - if action in action_map: - re_data = {'action': action_map[action], 'actiondata': data['actiondata']} - de_packet = {'type':'CHAT_ACTION_S', 'data':re_data} - flag = db.Chat.save_msg(user, data) - if flag == 'SUCCESS': - await broadcast_packet(SESSIONS, SERVER_CREDS, members, de_packet) - return await get_resp_packet(SESSIONS, ws, {'type':'STATUS', 'data':{'sig':'SENT'}}) - elif flag == 'NOT_YOURS': - return await get_resp_packet(SESSIONS, ws, {'type':'STATUS', 'data':{'sig':'MSG_NOT_YOURS'}}) - elif flag == 'FORMAT_ERR': - return await get_resp_packet(SESSIONS, ws, {'type':'STATUS', 'data':{'sig':'UNSUPPORTED_MSG_FORMAT'}}) - else: - return await get_resp_packet(SESSIONS, ws, {'type':'STATUS', 'data':'NOT_IN_ROOM'}) + return await get_resp_packet(SESSIONS, ws, {'type': 'STATUS', 'data': {'sig': 'TOKEN_INVALID'}}) + -async def sync_chat(SESSIONS, SERVER_CREDS, ws, data): +async def logout(SESSIONS, SERVER_CREDS, ws, data): sender = await identify_client(ws, SESSIONS) user = SESSIONS[sender][2] - room = db.Room.fetch_info(data['room']) - if parse_time(data['from']) == 'PARSE_ERR' or parse_time(data['to']) == 'PARSE_ERR': - return await get_resp_packet(SESSIONS, ws, {'type':'STATUS', 'data':'SYNC_INVALID_TIMESTAMP'}) - - if room == 'ROOM_DNE': - return await get_resp_packet(SESSIONS, ws, {'type':'STATUS', 'data':'ROOM_DNE'}) - elif room[1] == user or user in pickle.loads(room[3]): - data = db.Chat.fetch_history(user, data['room'], data['from'], data['to']) - sync_data = pickle.dumps(data) - de_packet = {'type':'SYNC_ROOM_DATA', 'data':{'room':room, 'sync_data':sync_data}} - return await get_resp_packet(SESSIONS, ws, de_packet) - else: - return await get_resp_packet(SESSIONS, ws, {'type':'STATUS', 'data':'NOT_IN_ROOM'}) + if not user: + return await get_resp_packet(SESSIONS, ws, {'type': 'STATUS', 'data': {'sig': 'NOT_LOGGED_IN'}}) + flag = db.Account.logout(user) + if flag == 'SUCCESS': + SESSIONS[sender][2] = None + print(log.tags.info + log.packet.logout_success.format(sender, ws.remote_address)) + print("[DEBUG] SESSIONS:", SESSIONS) + return await get_resp_packet(SESSIONS, ws, {'type': 'STATUS', 'data': {'sig': 'LOGOUT_OK'}}) + elif flag == 'FAILURE': + return await get_resp_packet(SESSIONS, ws, {'type': 'STATUS', 'data': {'sig': 'LOGOUT_ERR'}}) + + +async def delete(SESSIONS, SERVER_CREDS, ws, data): + try: + identifier = data['id'] + password = data['password'] + except KeyError as ero: + return await get_resp_packet(SESSIONS, ws, + {'type': 'STATUS', 'data': {'sig': 'MISSING_CREDS', 'desc': ero}}) + resp_captcha = await captcha(SESSIONS, SERVER_CREDS, ws, data) + if resp_captcha is True: + print("DEBUG - True") + flag = db.Account.check_pwd(password, identifier) + print("DEBUG - Passwd check") + if flag[0] is True: + user = flag[1] + print("DEBUG - identifier to uuid") + dflag = db.Account.delete(user) + print("DEBUG - delete complete") + if dflag == 'SUCCESS': + print(log.tags.info + log.packet.acc_delete.format(identifier)) + return await get_resp_packet(SESSIONS, ws, {'type': 'STATUS', 'data': {'sig': 'ACC_DELETE_SUCCESS'}}) + elif dflag == 'FAILURE': + return await get_resp_packet(SESSIONS, ws, {'type': 'STATUS', 'data': {'sig': 'ACC_DELETE_ERR'}}) + elif flag[0] is False: + return await get_resp_packet(SESSIONS, ws, {'type': 'STATUS', 'data': {'sig': 'INCORRECT_PASSWORD'}}) + elif flag == 'ACCOUNT_DNE': + return await get_resp_packet(SESSIONS, ws, {'type': 'STATUS', 'data': {'sig': 'DELETE_NONEXISTENT_ACCOUNT'}}) + elif resp_captcha is False: + return await get_resp_packet(SESSIONS, ws, {'type': 'STATUS', 'data': {'sig': 'CAPTCHA_WRONG'}}) async def captcha(SESSIONS, SERVER_CREDS, ws, data): uuid = await identify_client(ws, SESSIONS) - challenge = str(randint(100000,999999)) + challenge = str(randint(100000, 999999)) data = ImageCaptcha().generate(challenge) image = data.getvalue() - packet = en.encrypt_packet({'type':'CAPTCHA', 'data':{'challenge':image}}, SESSIONS[uuid][1]) + packet = en.encrypt_packet({'type': 'CAPTCHA', 'data': {'challenge': image}}, SESSIONS[uuid][1]) await ws.send(packet) - print(f"[INFO] GENERATED CAPTCHA FOR CLIENT {uuid} with CODE {challenge}") + print(log.tags.debug + log.packet.captcha_gen.format(uuid, challenge)) # This line is not meant for production resp = await ws.recv() # handle possible INVALID_PACKET in next line - de_resp = pickle.loads(en.decrypt_packet(resp, SERVER_CREDS['server_eprkey'])) + de_resp = en.decrypt_packet(resp, SERVER_CREDS['server_eprkey']) de_resp = de_resp['data']['solved'] return int(de_resp) == int(challenge) -upacket_map = { - 'CONN_INIT':1, - 'CONN_ENCRYPT_C':2 -} + packet_map = { - 'SIGNUP':signup, - 'LOGIN':login, - 'AUTH_TOKEN':auth, - 'CREATE_ROOM':create_room, - 'CHAT_ACTION':chat_action, - 'SYNC_ROOM_REQ':sync_chat + 'SIGNUP': signup, + 'LOGIN': login, + 'AUTH_TOKEN': auth, + 'LOGOUT': logout, + 'DELETE': delete } + async def handle(SESSIONS, SERVER_CREDS, packet, ws): if 'type'.encode() in packet: de_packet = pickle.loads(packet) else: de_packet = en.decrypt_packet(packet, SERVER_CREDS['server_eprkey']) - if de_packet['type'] == 'INVALID_PACKET': await disconnect(ws, 1008, "Invalid Packet Structure") return 'CONN_CLOSED' else: - type = de_packet['type'] + ptype = de_packet['type'] data = de_packet['data'] - if type == 'CONN_INIT': + if ptype == 'CONN_INIT': return await establish_conn(SESSIONS, SERVER_CREDS, ws) - elif type in packet_map.keys(): - func = packet_map[type] + elif ptype in packet_map.keys(): + func = packet_map[ptype] return await func(SESSIONS, SERVER_CREDS, ws, data) else: await disconnect(ws, 1008, "Invalid Packet Structure") return 'CONN_CLOSED' -