diff --git a/delivery2/README.md b/delivery2/README.md index 8b13789..06ee4b8 100644 --- a/delivery2/README.md +++ b/delivery2/README.md @@ -1 +1,126 @@ +# SIO 2024 - Projeto 1 +## Group + +- João Pedro Fonseca Bastos - 113470 - joaop.bastos@ua.pt +- Rúben da Loura Cristóvão Gomes - 113435 - rlcg@ua.pt +- Tiago Rocha Garcia - 114184 - tiago.rgarcia@ua.pt + +## API + +### Usage + +The API run as a RESTful service using the flask framework. A test API is hosted on the following URL: `https://sio.tiagorg.pt`. + +To run, first create the virtual environment and install the dependencies: + +```bash +python3 -m venv .venv +source .venv/bin/activate +pip install -r requirements.txt +``` + +Then, run the API: + +```bash +flask run --port +``` + +*Note: The API is hosted on port 5000 by default.* +*Note: You can also run the API in debug mode with the flag `--debug`.* + +### Endpoints + +The API has a list of endpoints that require different permission levels to access. + +Mainly, it's divided into 3 categories: + +- Anonymous: No authentication required. +- Authenticated: Authentication required. +- Authorized: Authentication and permissions required. + +#### Anonymous Endpoints + +- `GET /`: Returns a ping message. +- `POST /reset`: Resets the database and deletes all data. + - Required headers: + - `Content-Type: application/json` + - Required payload fields: + - `password`: The reset password. *Note: The reset password is `123`.* +- `GET /org/list`: Returns a list of organizations. +- `POST /org/create`: Creates a new organization. + - Required headers: + - `Content-Type: application/json` + - Required payload fields: + - `name`: Organization name. + - `username`: Manager username. + - `full_name`: Manager full name. + - `email`: Manager email. + - `public_key`: Manager public key. +- `GET /file/get//content`: Downloads the file content. +- `POST /user/login`: Logs in a user. + - Required headers: + - `Content-Type: application/json` + - Required payload fields: + - `org`: Organization name. + - `username`: User username. + - `password`: User password. + - `credentials_file`: User credentials file. + +#### Authenticated Endpoints + +- `GET /user/list`: Returns a list of users. + - Required headers: + - `Authorization: token` + - Optional payload parameters: + - `username`: Filter by username. +- `GET /file/list`: Returns a list of files. + - Required headers: + - `Authorization: token` + - Optional payload parameters: + - `username`: Filter by username. + - `datetime`: Filter by datetime. The datetime filter has the following fields: + - `value`: Epoch time in seconds. + - `relation`: `ot` | `eq` | `nt`. (One of the following: older than, equal to, newer than) +- `POST /user/logout`: Logs out a user. + - Required headers: + - `Authorization: token` + +#### Authorized Endpoints + +- `POST /user/create`: Creates a new user. + - Required headers: + - `Authorization: token` + - `Content-Type: application/json` + - Required payload fields: + - `username`: User username. + - `name`: User name. + - `email`: User email. + - `public_key`: User public key. +- `POST /user//suspend`: Suspends a user. + - Required headers: + - `Authorization: token` +- `POST /user//activate`: Activates a user. + - Required headers: + - `Authorization: token` +- `POST /file/upload/metadata`: Uploads file metadata. + - Required headers: + - `Authorization: token` + - `Content-Type: application/json` + - Required payload fields: + - `document_name`: Document name. + - `key`: Document key. + - `alg`: Document algorithm. + - `nonce`: Document nonce. +- `POST /file/upload/content`: Uploads file content, content-type must be `multipart/form-data`. + - Required headers: + - `Authorization: token` + - `Content-Type: multipart/form-data` + - Required payload fields: + - `content`: Document content. +- `GET /file/get//metadata`: Downloads file metadata. + - Required headers: + - `Authorization: token` +- `POST /file/delete/`: Deletes a file. + - Required headers: + - `Authorization: token \ No newline at end of file diff --git a/delivery2/client/bin/rep_activate_subject b/delivery2/client/bin/rep_activate_subject new file mode 100755 index 0000000..1bec38c --- /dev/null +++ b/delivery2/client/bin/rep_activate_subject @@ -0,0 +1,54 @@ +#!/bin/python3 +import os +import sys +import logging +import requests +import json +import argparse + +from subject import main + +logging.basicConfig(format='%(levelname)s\t- %(message)s') +logger = logging.getLogger() +logger.setLevel(logging.INFO) + +state = main(sys.argv) + +BASE_DIR = os.path.join(os.path.expanduser('~'), '.sio/') + +#session file - username +def activateSubject(args): + parser = argparse.ArgumentParser() + + parser.add_argument("-k", '--key', nargs=1, help="Path to the key file") + parser.add_argument("-r", '--repo', nargs=1, help="Address:Port of the repository") + parser.add_argument("-v", '--verbose', help="Increase verbosity", action="store_true") + + parser.add_argument('session', nargs='?', default=None) + parser.add_argument('username', nargs='?',default=None) + + args = parser.parse_args() + + if not args.session or not args.username: + logger.error("Need session file and username.") + sys.exit(1) + + if (not os.path.isfile(BASE_DIR + args.session)): + logger.error("File '" + args.session + "' not found.") + sys.exit(1) + + # Get session file content + with open(BASE_DIR + args.session, 'r') as f: + args.session = json.load(f) + + try: + req = requests.post(f'http://{state['REP_ADDRESS']}/user/' + args.username + '/activate', headers={'Authorization': args.session['token']}) + req.raise_for_status() + except requests.exceptions.RequestException as errex: + logger.error("Failed to obtain response from server.") + sys.exit(-1) + + sys.exit(0) + +if __name__ == '__main__': + activateSubject(sys.argv[1:]) \ No newline at end of file diff --git a/delivery2/client/bin/rep_add_doc b/delivery2/client/bin/rep_add_doc new file mode 100755 index 0000000..8576080 --- /dev/null +++ b/delivery2/client/bin/rep_add_doc @@ -0,0 +1,88 @@ +#!/bin/python3 +import os +import sys +import logging +import requests +import json +import argparse + +from subject import main + +sys.path.append(os.path.abspath("../../")) +from lib.symmetric_encryption import * +from lib import digest + +logging.basicConfig(format='%(levelname)s\t- %(message)s') +logger = logging.getLogger() +logger.setLevel(logging.INFO) + +state = main(sys.argv) + +BASE_DIR = os.path.join(os.path.expanduser('~'), '.sio/') + +# session file - document name - file +def addDoc(args): + parser = argparse.ArgumentParser() + + parser.add_argument("-k", '--key', nargs=1, help="Path to the key file") + parser.add_argument("-r", '--repo', nargs=1, help="Address:Port of the repository") + parser.add_argument("-v", '--verbose', help="Increase verbosity", action="store_true") + + parser.add_argument('session', nargs='?', default=None) + parser.add_argument('name', nargs='?', default=None) + parser.add_argument('file', nargs='?', default=None) + + args = parser.parse_args() + + if not args.session or not args.name or not args.file: + logger.error("Need session file, document's name and file to upload.") + sys.exit(1) + + if (not os.path.isfile(os.path.join(BASE_DIR, args.session))): + logger.error("File '" + args.session + "' not found.") + sys.exit(1) + + if (not os.path.isfile(os.path.join(BASE_DIR, args.file))): + logger.error("File '" + args.file + "' not found") + sys.exit(1) + + #Get session file content + with open(os.path.join(BASE_DIR, args.session), 'r') as f: + args.session = json.load(f) + + #Encrypt content + key, content, nonce = encrypt_file(BASE_DIR + args.file, BASE_DIR + 'encryptedText') + + #Upload document metadata + doc = {'document_name' : args.name, 'key' : key.hex(), 'alg' : 'AES-CFB', 'nonce' : nonce.hex() } + + try: + req = requests.post(f'http://{state['REP_ADDRESS']}/file/upload/metadata', json=json.dumps(doc), + headers={'Authorization': args.session['token']}) + req.raise_for_status() + + except requests.exceptions.RequestException as errex: + logger.error("Failed to obtain response from server.") + sys.exit(-1) + + #Upload Document content + file = {'file' : (BASE_DIR + args.file, content)} + + try: + req = requests.post(f'http://{state['REP_ADDRESS']}/file/upload/content', + files=file, + headers={'Authorization': args.session['token'], 'File-Checksum' : digest.get_hash(content)}) + req.raise_for_status() + + except requests.exceptions.RequestException as errex: + logger.error("Failed to obtain response from server.") + os.remove(BASE_DIR + 'encryptedText') + sys.exit(1) + + #Delete temporary file + os.remove(BASE_DIR + 'encryptedText') + + sys.exit(0) + +if __name__ == '__main__': + addDoc(sys.argv[1:]) \ No newline at end of file diff --git a/delivery2/client/bin/rep_add_subject b/delivery2/client/bin/rep_add_subject new file mode 100755 index 0000000..0ff3c2c --- /dev/null +++ b/delivery2/client/bin/rep_add_subject @@ -0,0 +1,69 @@ +#!/bin/python3 +import os +import sys +import logging +import requests +import json +import argparse + +from subject import main + +sys.path.append(os.path.abspath('../../')) +from lib import asymmetric_functs + +logging.basicConfig(format='%(levelname)s\t- %(message)s') +logger = logging.getLogger() +logger.setLevel(logging.INFO) + +state = main(sys.argv) + +BASE_DIR = os.path.join(os.path.expanduser('~'), '.sio/') + +# session file - username - name - email - credentials file +def addSubject(args): + parser = argparse.ArgumentParser() + + parser.add_argument("-k", '--key', nargs=1, help="Path to the key file") + parser.add_argument("-r", '--repo', nargs=1, help="Address:Port of the repository") + parser.add_argument("-v", '--verbose', help="Increase verbosity", action="store_true") + + parser.add_argument('session', nargs='?', default=None) + parser.add_argument('username', nargs='?', default=None) + parser.add_argument('name', nargs='?', default=None) + parser.add_argument('email', nargs='?', default=None) + parser.add_argument('credentials', nargs='?', default=None) + + args = parser.parse_args() + + if not args.session or not args.username or not args.name or not args.email or not args.credentials: + logger.error("Need session file, username, name, email and credentials file.") + sys.exit(1) + + if (not os.path.isfile(BASE_DIR + args.session)): + logger.error("File '" + args.session + "' not found.") + sys.exit(1) + + # Get session file content + with open(BASE_DIR + args.session, 'r') as f: + args.session = json.load(f) + + if (not os.path.isfile(BASE_DIR + args.credentials)): + logger.error("File '" + args.file + "' not found") + sys.exit(1) + + pubKey = asymmetric_functs.load_public_key(BASE_DIR + args.credentials) + + subject = {'username' : args.username, 'full_name' : args.name, 'email' : args.email, 'public_key' : pubKey} + + try: + req = requests.post(f'http://{state['REP_ADDRESS']}/user/create', json=json.dumps(subject), headers={'Authorization': args.session['token']}) + req.raise_for_status() + + except requests.exceptions.RequestException as errex: + logger.error("Failed to obtain response from server.") + sys.exit(-1) + + sys.exit(0) + +if __name__ == '__main__': + addSubject(sys.argv[1:]) \ No newline at end of file diff --git a/delivery2/client/bin/rep_create_org b/delivery2/client/bin/rep_create_org new file mode 100755 index 0000000..71b9a1d --- /dev/null +++ b/delivery2/client/bin/rep_create_org @@ -0,0 +1,71 @@ +#!/bin/python3 +import os +import sys +import logging +import requests +import json +import re +import argparse + +sys.path.append(os.path.abspath("../")) +from subject import main + +sys.path.append(os.path.abspath('../../')) +from lib import asymmetric_functs + +logging.basicConfig(format='%(levelname)s\t- %(message)s') +logger = logging.getLogger() +logger.setLevel(logging.INFO) + +state = main(sys.argv) + +BASE_DIR = os.path.join(os.path.expanduser('~'), '.sio/') + +# Create organization +# organization - username - name - email - public key file +def createOrganization(args): + + parser = argparse.ArgumentParser() + + parser.add_argument("-k", '--key', nargs=1, help="Path to the key file") + parser.add_argument("-r", '--repo', nargs=1, help="Address:Port of the repository") + parser.add_argument("-v", '--verbose', help="Increase verbosity", action="store_true") + + parser.add_argument('org', nargs='?', default=None) + parser.add_argument('username', nargs='?', default=None) + parser.add_argument('name', nargs='?', default=None) + parser.add_argument('email', nargs='?', default=None) + parser.add_argument('pubkey', nargs='?', default=None) + + args = parser.parse_args() + + if not args.org or not args.username or not args.name or not args.email or not args.pubkey: + logger.error("Need organization, username, name, email and key file.") + sys.exit(1) + + # Validate email + if not re.match(r'^[a-zA-Z0-9._%+-]+@[a-zA-Z0-9.-]+\.[a-zA-Z]{2,}$', args.email): + logger.error("Need a valid email.") + sys.exit(1) + + # Validate key file + if (not os.path.isfile(BASE_DIR + args.pubkey)): + logger.error("File '" + args.pubkey + "' not found.") + sys.exit(1) + + # load public key from file + pubKey = asymmetric_functs.load_public_key(BASE_DIR + args.pubkey) + + input = {'name' : args.org, 'username' : args.username, 'full_name' : args.name, 'email' : args.email, 'public_key' : pubKey} + + try: + req = requests.post(f'http://{state['REP_ADDRESS']}/org/create', json=json.dumps(input)) + req.raise_for_status() + except requests.exceptions.RequestException as errex: + logger.error("Failed to obtain response from server.") + sys.exit(-1) + + sys.exit(0) + +if __name__ == '__main__': + createOrganization(sys.argv[1:]) diff --git a/delivery2/client/bin/rep_create_session b/delivery2/client/bin/rep_create_session new file mode 100755 index 0000000..ea3d781 --- /dev/null +++ b/delivery2/client/bin/rep_create_session @@ -0,0 +1,64 @@ +#!/bin/python3 +import os +import sys +import argparse +import logging +import json +import requests + +from subject import main + +# Identity attributes +# {'username' : '', 'full_name' : '', 'email' : '', public_key : '' } + +logging.basicConfig(format='%(levelname)s\t- %(message)s') +logger = logging.getLogger() +logger.setLevel(logging.INFO) + +state = main(sys.argv) + +BASE_DIR = os.path.join(os.path.expanduser('~'), '.sio/') + +# org - username - password - credentials file - session file +def createSession(args): + + parser = argparse.ArgumentParser() + + parser.add_argument("-k", '--key', nargs=1, help="Path to the key file") + parser.add_argument("-r", '--repo', nargs=1, help="Address:Port of the repository") + parser.add_argument("-v", '--verbose', help="Increase verbosity", action="store_true") + + parser.add_argument('org', nargs='?', default=None) + parser.add_argument('username', nargs='?', default=None) + parser.add_argument('password', nargs='?', default=None) + parser.add_argument('credentials', nargs='?', default=None) + parser.add_argument('session', nargs='?', default=None) + + args = parser.parse_args() + + if not args.org or not args.username or not args.password or not args.credentials or not args.session: + logger.error("Need organization, username, password, credentials and session file") + sys.exit(1) + + if (not os.path.isfile(BASE_DIR + args.credentials)): + logger.error("File '" + args.credentials + "' not found.") + sys.exit(1) + + session = {'org' : args.org, 'username' : args.username, 'password' : args.password, 'credentials_file' : args.credentials} + + #print( type(json.dumps(session))) + + try: + req = requests.post(f'http://{state['REP_ADDRESS']}/user/login', json=json.dumps(session)) + req.raise_for_status() + except requests.exceptions.RequestException as errex: + logger.error("Failed to obtain response from server") + sys.exit(-1) + + with open(BASE_DIR + args.session, 'w') as f: + json.dump(req.json(), f) + + sys.exit(0) + +if __name__ == '__main__': + createSession(sys.argv[1:]) \ No newline at end of file diff --git a/delivery2/client/bin/rep_decrypt_file b/delivery2/client/bin/rep_decrypt_file new file mode 100755 index 0000000..373ca3a --- /dev/null +++ b/delivery2/client/bin/rep_decrypt_file @@ -0,0 +1,53 @@ +#!/bin/python3 +import os +import sys +import logging +import argparse +import json + +sys.path.append(os.path.abspath("../../")) +from lib import symmetric_encryption + +logging.basicConfig(format='%(levelname)s\t- %(message)s') +logger = logging.getLogger() +logger.setLevel(logging.INFO) + +BASE_DIR = os.path.join(os.path.expanduser('~'), '.sio/') + +#send to stdout contents of decrypted file +# encrypted file - encryption metadata +def decryptFile(args): + + parser = argparse.ArgumentParser() + + parser.add_argument('encrypted', nargs='?', default=None) + parser.add_argument('metadata', nargs='?', default=None) + + args = parser.parse_args() + + if not args.encrypted or not args.metadata: + logger.error("Need encrypted file and it's metadata.") + sys.exit(1) + + # If first argument is not a file or not found + if (not os.path.isfile(BASE_DIR + args.encrypted)): + logger.error("File '" + args.encrypted + "' not found.") + sys.exit(1) + + # if (not os.path.isfile(BASE_DIR + args.metadata)): + # logger.error("File '" + args.metadata + "' not found.") + # sys.exit(1) + + #Decrypt file + print(args.metadata) + metadata = json.loads(args.metadata) + + content = symmetric_encryption.decrypt_file(metadata['nonce'].encode(), metadata['key'].encode(), BASE_DIR + args.encrypted) + + # Send decrypted content to stdout + sys.stdout.write(content) + + sys.exit(0) + +if __name__ == '__main__': + decryptFile(sys.argv[1:]) diff --git a/delivery2/client/bin/rep_delete_doc b/delivery2/client/bin/rep_delete_doc new file mode 100755 index 0000000..dc1ccf6 --- /dev/null +++ b/delivery2/client/bin/rep_delete_doc @@ -0,0 +1,60 @@ +#!/bin/python3 +import os +import sys +import logging +import requests +import json +import argparse + +sys.path.append(os.path.abspath("../../")) +from lib import digest + +from subject import main + +logging.basicConfig(format='%(levelname)s\t- %(message)s') +logger = logging.getLogger() +logger.setLevel(logging.INFO) + +state = main(sys.argv) + +BASE_DIR = os.path.join(os.path.expanduser('~'), '.sio/') + +# session file - document name +def delDoc(args): + parser = argparse.ArgumentParser() + + parser.add_argument("-k", '--key', nargs=1, help="Path to the key file") + parser.add_argument("-r", '--repo', nargs=1, help="Address:Port of the repository") + parser.add_argument("-v", '--verbose', help="Increase verbosity", action="store_true") + + parser.add_argument('session', nargs='?', default=None) + parser.add_argument('name', nargs='?', default=None) + + args = parser.parse_args() + + if not args.session or not args.name: + logger.error("Need session file and document's name.") + sys.exit(1) + + if (not os.path.isfile(BASE_DIR + args.session)): + logger.error("File '" + args.session + "' not found.") + sys.exit(1) + + # Get session file content + with open(BASE_DIR + args.session, 'r') as f: + args.session = json.load(f) + + doc_name = digest.get_hash(bytes(args.name, encoding='utf-8')) + + try: + req = requests.post(f'http://{state['REP_ADDRESS']}/file/delete/' + doc_name, headers={'Authorization': args.session['token']}) + req.raise_for_status() + + except requests.exceptions.RequestException as errex: + logger.error("Failed to obtain response from server.") + sys.exit(-1) + + sys.exit(0) + +if __name__ == '__main__': + delDoc(sys.argv[1:]) \ No newline at end of file diff --git a/delivery2/client/bin/rep_get_doc_file b/delivery2/client/bin/rep_get_doc_file new file mode 100755 index 0000000..d8f1037 --- /dev/null +++ b/delivery2/client/bin/rep_get_doc_file @@ -0,0 +1,92 @@ +#!/bin/python3 +import os +import sys +import logging +import requests +import json +import argparse + +from subject import main + +sys.path.append(os.path.abspath("../../")) +from lib import digest +from lib import symmetric_encryption + +logging.basicConfig(format='%(levelname)s\t- %(message)s') +logger = logging.getLogger() +logger.setLevel(logging.INFO) + +state = main(sys.argv) + +BASE_DIR = os.path.join(os.path.expanduser('~'), '.sio/') + +# session file - document name - output file(optional) +def getDoc(args): + parser = argparse.ArgumentParser() + + parser.add_argument("-k", '--key', nargs=1, help="Path to the key file") + parser.add_argument("-r", '--repo', nargs=1, help="Address:Port of the repository") + parser.add_argument("-v", '--verbose', help="Increase verbosity", action="store_true") + + parser.add_argument('session', nargs='?', default=None) + parser.add_argument('name', nargs='?', default=None) + parser.add_argument('output', nargs='?', default=None) + + args = parser.parse_args() + + if not args.session or not args.name: + logger.error("Need session file and document's name.") + sys.exit(-1) + + if (not os.path.isfile(BASE_DIR + args.session)): + logger.error("File '" + args.session + "' not found.") + sys.exit(-1) + + # Get session file content + with open(BASE_DIR + args.session, 'r') as f: + args.session = json.load(f) + + # Get Document metadata + + doc_name = digest.get_hash(bytes(args.name, encoding='utf-8')) + + try: + metadata = requests.get(f'http://{state['REP_ADDRESS']}/file/get/' + doc_name + '/metadata', headers={'Authorization': args.session['token']}) + metadata.raise_for_status() + + except requests.exceptions.RequestException as errex: + logger.error("Failed to obtain response from server.") + sys.exit(1) + + metadata = metadata.json() + + #Get file with file_handle provided by metadata + + try: + file = requests.get(f'http://{state['REP_ADDRESS']}/get/' + metadata['file_handle'] + '/content') + file.raise_for_status() + except requests.exceptions.RequestException as errex: + logger.error("Failed to obtain response from server.") + sys.exit(-1) + + file = file.json() + + if not digest.get_hash(file) == metadata['file_handle']: + logger.error("Files integrity is lost.") + sys.exit(-1) + + content = symmetric_encryption.decrypt_file(file) + + if args.output: + with open(BASE_DIR + args.output, 'w') as f: + f.write(content) + + else: + sys.stdout.write(content) + + + + sys.exit(0) + +if __name__ == '__main__': + getDoc(sys.argv[1:]) \ No newline at end of file diff --git a/delivery2/client/bin/rep_get_doc_metadata b/delivery2/client/bin/rep_get_doc_metadata new file mode 100755 index 0000000..564cf1d --- /dev/null +++ b/delivery2/client/bin/rep_get_doc_metadata @@ -0,0 +1,64 @@ +#!/bin/python3 +import os +import sys +import logging +import requests +import json +import argparse + +sys.path.append(os.path.join(os.path.dirname(__file__), '..', '..')) +from lib import digest + +from subject import main + +logging.basicConfig(format='%(levelname)s\t- %(message)s') +logger = logging.getLogger() +logger.setLevel(logging.INFO) + +state = main(sys.argv) + +BASE_DIR = os.path.join(os.path.expanduser('~'), '.sio/') + +# session file - document name +def getDocMetadata(args): + parser = argparse.ArgumentParser() + + + parser.add_argument("-k", '--key', nargs=1, help="Path to the key file") + parser.add_argument("-r", '--repo', nargs=1, help="Address:Port of the repository") + parser.add_argument("-v", '--verbose', help="Increase verbosity", action="store_true") + + parser.add_argument('session', nargs='?', default=None) + parser.add_argument('name', nargs='?', default=None) + + args = parser.parse_args() + + if not args.session or not args.name: + logger.error("Need session file and document's name.") + sys.exit(1) + + if (not os.path.isfile(BASE_DIR + args.session)): + logger.error("File '" + args.session + "' not found.") + sys.exit(1) + + # Get session file content + with open(BASE_DIR + args.session, 'r') as f: + args.session = json.load(f) + + doc_name = digest.get_hash(bytes(args.name, encoding='utf-8')) + + try: + metadata = requests.get(f'http://{state['REP_ADDRESS']}/file/get/' + doc_name + '/metadata', headers={'Authorization': args.session['token']}) + metadata.raise_for_status() + except requests.exceptions.RequestException as errex: + logger.error("Failed to obtain response from server.") + sys.exit(-1) + + metadata = metadata.json() + + sys.stdout.write(json.dumps(metadata)) + + sys.exit(0) + +if __name__ == '__main__': + getDocMetadata(sys.argv[1:]) \ No newline at end of file diff --git a/delivery2/client/bin/rep_get_file b/delivery2/client/bin/rep_get_file new file mode 100755 index 0000000..fe8a95d --- /dev/null +++ b/delivery2/client/bin/rep_get_file @@ -0,0 +1,64 @@ +#!/bin/python3 +import os +import sys +import logging +import json +import requests +import argparse + +from subject import main + +# Identity attributes +# {'username' : '', 'full_name' : '', 'email' : '', public_key : '' } + +logging.basicConfig(format='%(levelname)s\t- %(message)s') +logger = logging.getLogger() +logger.setLevel(logging.INFO) + +state = main(sys.argv) + +BASE_DIR = os.path.join(os.path.expanduser('~'), '.sio/') + +#get file +#file handle - file(optonal) +def getFile(args): + parser = argparse.ArgumentParser() + + parser.add_argument("-k", '--key', nargs=1, help="Path to the key file") + parser.add_argument("-r", '--repo', nargs=1, help="Address:Port of the repository") + parser.add_argument("-v", '--verbose', help="Increase verbosity", action="store_true") + + parser.add_argument('filehandle', nargs='?', default=None) + parser.add_argument('file', nargs='?', default=None) + + args = parser.parse_args() + + + if not args.filehandle: + logger.error("Need a file handle.") + sys.exit(1) + #else: + # if not os.path.isfile(BASE_DIR + args.filehandle): + # logger.error("File '" + args.filehandle + "' not found" ) + # sys.exit(1) + + #Get file + try: + file = requests.get(f'http://{state['REP_ADDRESS']}/file/get/' + args.filehandle + '/content') + file.raise_for_status() + except requests.exceptions.RequestException as errex: + logger.error("Failed to obtain response from server.") + sys.exit(-1) + + print(file) + + if not args.file: + sys.stdout.write(file.text) + else: + with open(BASE_DIR + args.file, "wb") as f: + f.write(file.content) + + sys.exit(0) + +if __name__ == '__main__': + getFile(sys.argv[1:]) \ No newline at end of file diff --git a/delivery2/client/bin/rep_list_docs b/delivery2/client/bin/rep_list_docs new file mode 100755 index 0000000..bd16aa0 --- /dev/null +++ b/delivery2/client/bin/rep_list_docs @@ -0,0 +1,128 @@ +#!/bin/python3 +import os +import sys +import logging +import requests +import json +import argparse +import datetime + +from subject import main + +logging.basicConfig(format='%(levelname)s\t- %(message)s') +logger = logging.getLogger() +logger.setLevel(logging.INFO) + +state = main(sys.argv) + +BASE_DIR = os.path.join(os.path.expanduser('~'), '.sio/') + +def validDate(input): + try: + date = datetime.datetime.strptime(input,"%d/%m/%Y") + except ValueError as err: + logger.error("Date is invalid. Input format: DD/MM/YYYY") + sys.exit(-1) + + return date + +#session file - [-s username] [-d nt/ot/et date] +def list_docs(args): + parser = argparse.ArgumentParser() + + parser.add_argument("-k", '--key', nargs=1, help="Path to the key file") + parser.add_argument("-r", '--repo', nargs=1, help="Address:Port of the repository") + parser.add_argument("-v", '--verbose', help="Increase verbosity", action="store_true") + + parser.add_argument('session', nargs='?', default=None) + parser.add_argument("-s", '--username', nargs=1, help="Username") + parser.add_argument("-dnt", '--newerThan', help="Date new than") + parser.add_argument("-dot", '--olderThan', help="Date older than") + parser.add_argument("-deq", '--equalTo', help="Date equal to") + + args = parser.parse_args() + + # Check if session file is valid + if args.session: + if (not os.path.isfile(BASE_DIR + args.session)): + logger.error("File '" + args.session + "' not found.") + sys.exit(1) + else: + logger.error("Need session file.") + sys.exit(1) + + # Get session file content + with open(BASE_DIR + args.session, 'r') as f: + args.session = json.load(f) + + if args.newerThan: + #Convert date str to datetime in seconds + args.newerThan = validDate(args.newerThan) + args.newerThan = int(args.newerThan.timestamp()) + + try: + subjects = requests.get(f'http://{state['REP_ADDRESS']}/file/list', + json=json.dumps({'username' : args.username,'datetime' : {'value' : args.newerThan, 'relation' : 'nt'}}), + headers={'Authorization': args.session['token']}) + subjects.raise_for_status() + + except requests.exceptions.RequestException as errex: + logger.error("Failed to obtain response from server.") + sys.exit(-1) + + subjects = subjects.json() + + elif args.equalTo: + #Convert date str to datetime in seconds + args.equalTo = validDate(args.equalTo) + args.equalTo = int(args.equalTo.timestamp()) + + try: + subjects = requests.get(f'http://{state['REP_ADDRESS']}/file/list', + json=json.dumps({'username' : args.username, + 'datetime' : {'value' : args.equalTo, 'relation' : 'eq'}}), + headers={'Authorization': args.session['token']}) + subjects.raise_for_status() + + except requests.exceptions.RequestException as errex: + logger.error("Failed to obtain response from server.") + sys.exit(-1) + + subjects = subjects.json() + + elif args.olderThan: + #Convert date str to datetime in seconds + args.olderThan = validDate(args.olderThan) + args.olderThan = int(args.olderThan.timestamp()) + + try: + subjects = requests.get(f'http://{state['REP_ADDRESS']}/file/list', + json=json.dumps({'username' : args.username, + 'datetime' : {'value' : args.olderThan, 'relation' : 'ot'}}), + headers={'Authorization': args.session['token']}) + subjects.raise_for_status() + + except requests.exceptions.RequestException as errex: + logger.error("Failed to obtain response from server.") + sys.exit(-1) + + subjects = subjects.json() + + else: + try: + subjects = requests.get(f'http://{state['REP_ADDRESS']}/file/list', json=json.dumps({}), headers={'Authorization': args.session['token']}) + subjects.raise_for_status() + + except requests.exceptions.RequestException as errex: + logger.error("Failed to obtain response from server.") + sys.exit(-1) + + subjects = subjects.json() + + for s in subjects: + sys.stdout.write(s['id'] + " - " + s['username']) + + sys.exit(0) + +if __name__ == '__main__': + list_docs(sys.argv[1:]) \ No newline at end of file diff --git a/delivery2/client/bin/rep_list_orgs b/delivery2/client/bin/rep_list_orgs new file mode 100755 index 0000000..147b881 --- /dev/null +++ b/delivery2/client/bin/rep_list_orgs @@ -0,0 +1,34 @@ +#!/bin/python3 +import os +import sys +import logging +import json +import requests + +from subject import main + +# Identity attributes +# {'username' : '', 'full_name' : '', 'email' : '', public_key : '' } + +logging.basicConfig(format='%(levelname)s\t- %(message)s') +logger = logging.getLogger() +logger.setLevel(logging.INFO) + +state = main(sys.argv) + +def listOrganizations(): + try: + orgs = requests.get(f'http://{state['REP_ADDRESS']}/org/list') + orgs.raise_for_status() + + except requests.exceptions.RequestException as errex: + logger.error("Failed to obtain response from server.") + sys.exit(-1) + + for org in orgs.json(): + sys.stdout.write(str(org['id']) + " - " + org['name']) + + sys.exit(0) + +if __name__ == '__main__': + listOrganizations() \ No newline at end of file diff --git a/delivery2/client/bin/rep_list_subjects b/delivery2/client/bin/rep_list_subjects new file mode 100755 index 0000000..75c2d07 --- /dev/null +++ b/delivery2/client/bin/rep_list_subjects @@ -0,0 +1,74 @@ +#!/bin/python3 +import os +import sys +import logging +import requests +import json +import argparse + +from subject import main + +logging.basicConfig(format='%(levelname)s\t- %(message)s') +logger = logging.getLogger() +logger.setLevel(logging.INFO) + +state = main(sys.argv) + +BASE_DIR = os.path.join(os.path.expanduser('~'), '.sio/') + +# session file - username(optional) +def list_subjects(args): + + parser = argparse.ArgumentParser() + + parser.add_argument("-k", '--key', nargs=1, help="Path to the key file") + parser.add_argument("-r", '--repo', nargs=1, help="Address:Port of the repository") + parser.add_argument("-v", '--verbose', help="Increase verbosity", action="store_true") + + parser.add_argument('session', nargs='?', default=None) + parser.add_argument('username', nargs='?', default=None) + + args = parser.parse_args() + + # Check if session file is valid + if args.session: + if (not os.path.isfile(BASE_DIR + args.session)): + logger.error("File '" + args.session + "' not found.") + sys.exit(1) + else: + logger.error("Need session file.") + sys.exit(1) + + # Get session file content + with open(BASE_DIR + args.session, 'r') as f: + args.session = json.load(f) + + if args.username: + try: + subjects = requests.get(f'http://{state['REP_ADDRESS']}/user/list', + json=json.dumps({'username' : args.username}), + headers={'Authorization': args.session['token']}) + subjects.raise_for_status() + + except requests.exceptions.RequestException as errex: + logger.error("Failed to obtain response from server.") + sys.exit(-1) + + else: + try: + subjects = requests.get(f'http://{state['REP_ADDRESS']}/user/list', + json=json.dumps({}), + headers={'Authorization': args.session['token']}) + subjects.raise_for_status() + + except requests.exceptions.RequestException as errex: + logger.error("Failed to obtain response from server.") + sys.exit(-1) + + for s,d in subjects.json().items(): + sys.stdout.write(s + " - " + d['username'] + "\n") + + sys.exit(0) + +if __name__ == '__main__': + list_subjects(sys.argv[1:]) \ No newline at end of file diff --git a/delivery2/client/bin/rep_subject_credentials b/delivery2/client/bin/rep_subject_credentials new file mode 100755 index 0000000..6cf0e7f --- /dev/null +++ b/delivery2/client/bin/rep_subject_credentials @@ -0,0 +1,40 @@ +#!/bin/python3 +import os +import sys +import logging +import argparse + +sys.path.append(os.path.abspath("../../")) +from lib import key_pair + +logging.basicConfig(format='%(levelname)s\t- %(message)s') +logger = logging.getLogger() +logger.setLevel(logging.INFO) + +BASE_DIR = os.path.join(os.path.expanduser('~'), '.sio/') + +# Generate a key pair for a subject +# password - file for public key, file for private key +def generateKeyPair(args): + + parser = argparse.ArgumentParser() + + parser.add_argument('password', nargs='?', default=None) + parser.add_argument('pubfile', nargs='?', default=None) + parser.add_argument('privfile', nargs='?', default=None) + + args = parser.parse_args() + + if not args.password or not args.pubfile or not args.privfile: + logger.error("Need password and file to store keys") + sys.exit(1) + + + #Generate the key pair + key_pair.generate_key_pair(BASE_DIR + args.pubfile, BASE_DIR + args.privfile, 2048, args.password) + + sys.exit(0) + +if __name__ == '__main__': + generateKeyPair(sys.argv[1:]) + diff --git a/delivery2/client/bin/rep_suspend_subject b/delivery2/client/bin/rep_suspend_subject new file mode 100755 index 0000000..f8a5147 --- /dev/null +++ b/delivery2/client/bin/rep_suspend_subject @@ -0,0 +1,55 @@ +#!/bin/python3 +import os +import sys +import logging +import requests +import json +import argparse + +sys.path.append(os.path.abspath("../")) +from subject import main + +logging.basicConfig(format='%(levelname)s\t- %(message)s') +logger = logging.getLogger() +logger.setLevel(logging.INFO) + +state = main(sys.argv) + +BASE_DIR = os.path.join(os.path.expanduser('~'), '.sio/') + +# session file - username +def suspendSubject(args): + parser = argparse.ArgumentParser() + + parser.add_argument("-k", '--key', nargs=1, help="Path to the key file") + parser.add_argument("-r", '--repo', nargs=1, help="Address:Port of the repository") + parser.add_argument("-v", '--verbose', help="Increase verbosity", action="store_true") + + parser.add_argument('session', nargs='?', default=None) + parser.add_argument('username', nargs='?',default=None) + + args = parser.parse_args() + + if not args.session or not args.username: + logger.error("Need session file and username.") + sys.exit(1) + + if (not os.path.isfile(BASE_DIR + args.session)): + logger.error("File '" + args.session + "' not found.") + sys.exit(1) + + # Get session file content + with open(BASE_DIR + args.session, 'r') as f: + args.session = json.load(f) + + try: + req = requests.post(f'http://{state['REP_ADDRESS']}/user/' + args.username + '/suspend', headers={'Authorization': args.session['token']}) + req.raise_for_status() + except requests.exceptions.RequestException as errex: + logger.error("Failed to obtain response from server.") + sys.exit(-1) + + sys.exit(0) + +if __name__ == '__main__': + suspendSubject(sys.argv[1:]) \ No newline at end of file diff --git a/delivery2/client/bin/reset_database b/delivery2/client/bin/reset_database new file mode 100755 index 0000000..73b49bb --- /dev/null +++ b/delivery2/client/bin/reset_database @@ -0,0 +1,42 @@ +#!/bin/python3 +import os +import sys +import logging +import requests +import json +import argparse + +sys.path.append(os.path.abspath("../")) +from subject import main + +logging.basicConfig(format='%(levelname)s\t- %(message)s') +logger = logging.getLogger() +logger.setLevel(logging.INFO) + +state = main(sys.argv) + +def reset(args): + + parser = argparse.ArgumentParser() + + parser.add_argument("-k", '--key', nargs=1, help="Path to the key file") + parser.add_argument("-r", '--repo', nargs=1, help="Address:Port of the repository") + parser.add_argument("-v", '--verbose', help="Increase verbosity", action="store_true") + + parser.add_argument('password', nargs='?', default=None) + + args = parser.parse_args() + + if not args.password: + logger.error("Need password") + sys.exit(1) + + try: + req = requests.post(f'http://{state['REP_ADDRESS']}/reset',json=json.dumps({'password' : args.password})) + req.raise_for_status() + except requests.exceptions.RequestException as errex: + logger.error("Failed to obtain response from server.") + sys.exit(-1) + +if __name__ == '__main__': + reset(sys.argv[1:]) diff --git a/delivery2/client/bin/subject.py b/delivery2/client/bin/subject.py new file mode 100755 index 0000000..94cf766 --- /dev/null +++ b/delivery2/client/bin/subject.py @@ -0,0 +1,116 @@ +import os +import sys +import argparse +import logging +import json +import requests + +# Identity attributes +# {'username' : '', 'full_name' : '', 'email' : '', public_key : '' } + +logging.basicConfig(format='%(levelname)s\t- %(message)s') +logger = logging.getLogger() +logger.setLevel(logging.INFO) + +def load_state(): + state = {} + state_dir = os.path.join(os.path.expanduser('~'), '.sio') + state_file = os.path.join(state_dir, 'state.json') + + logger.debug('State folder: ' + state_dir) + logger.debug('State file: ' + state_file) + + if os.path.exists(state_file): + logger.debug('Loading state') + with open(state_file,'r') as f: + state = json.loads(f.read()) + + if state is None: + state = {} + + return state + +def parse_env(state): + if 'REP_ADDRESS' in os.environ: + state['REP_ADDRESS'] = os.getenv('REP_ADDRESS') + logger.debug('Setting REP_ADDRESS from Environment to: ' + state['REP_ADDRESS']) + + if 'REP_PUB_KEY' in os.environ: + rep_pub_key = os.getenv('REP_PUB_KEY') + logger.debug('Loading REP_PUB_KEY fron: ' + state['REP_PUB_KEY']) + if os.path.exists(rep_pub_key): + with open(rep_pub_key, 'r') as f: + state['REP_PUB_KEY'] = f.read() + logger.debug('Loaded REP_PUB_KEY from Environment') + return state + +def parse_args(state): + parser = argparse.ArgumentParser() + + parser.add_argument("-k", '--key', nargs=1, help="Path to the key file") + parser.add_argument("-r", '--repo', nargs=1, help="Address:Port of the repository") + parser.add_argument("-v", '--verbose', help="Increase verbosity", action="store_true") + parser.add_argument("-c", "--command", help="Command to execute") + parser.add_argument('arg0', nargs='?', default=None) + parser.add_argument('arg1', nargs='?', default=None) + parser.add_argument('arg2', nargs='?', default=None) + parser.add_argument('arg3', nargs='?', default=None) + parser.add_argument('arg4', nargs='?', default=None) + parser.add_argument('arg5', nargs='?', default=None) + + args = parser.parse_args() + if args.verbose: + logger.setLevel(logging.DEBUG) + logger.info('Setting log level to DEBUG') + + if args.key: + if not os.path.exists(args.key[0]) or not os.path.isfile(args.key[0]): + logger.error(f'Key file not found or invalid: {args.key[0]}') + sys.exit(1) + + with open(args.key[0], 'r') as f: + state['REP_PUB_KEY'] = f.read() + logger.info('Overriding REP_PUB_KEY from command line') + + if args.repo: + state['REP_ADDRESS'] = args.repo[0] + logger.info('Overriding REP_ADDRESS from command line') + + #if args.command: + # logger.info("Command: " + args.command) + + return state#, {'command': args.command, 'arg0': args.arg0, 'arg1': args.arg1, 'arg2': args.arg2, 'arg3': args.arg3, 'arg4': args.arg4, 'arg5': args.arg5} + +def save(state): + state_dir = os.path.join(os.path.expanduser('~'), '.sio') + state_file = os.path.join(state_dir, 'state.json') + + if not os.path.exists(state_dir): + logger.debug('Creating state folder') + os.mkdir(state_dir) + + with open(state_file, 'w') as f: + f.write(json.dumps(state, indent=4)) + +#Main function for checking commands +def main(args): + + state = load_state() + state = parse_env(state) + #state, args = parse_args(state) + state = parse_args(state) + + if 'REP_ADDRESS' not in state: + logger.error("Must define Repository Address") + sys.exit(1) + + # if 'REP_PUB_KEY' not in state: + # logger.error("Must set the Repository Public Key") + # sys.exit(1) + + save(state) + + return state + +if __name__ == '__main__': + main(sys.argv) diff --git a/delivery2/client/tests/test_client.py b/delivery2/client/tests/test_client.py new file mode 100644 index 0000000..1624c4a --- /dev/null +++ b/delivery2/client/tests/test_client.py @@ -0,0 +1,128 @@ +import json +import os, subprocess, sys + +import requests + +DELIVERY_PATH = os.path.abspath(os.path.join(os.path.dirname(__file__), "..", "..")) +FILES_PATH = os.path.join(os.path.expanduser('~'), '.sio/') + +# !!! database.db must be deleted/reset before running the tests !!! +requests.post("http://localhost:5000/reset", json={"password": "123"}) +os.system(f"rm {FILES_PATH}*") + +def test_address_set(): + # Initialize the server path on state.json + process = subprocess.Popen(f"python3 {DELIVERY_PATH}/client/bin/subject.py -r localhost:5000 ", shell=True) + process.wait() + assert os.path.exists(os.path.join(FILES_PATH, 'state.json')) + + +def test_rep_subject_credentials(): + # Test the rep_subject_create command + process = subprocess.Popen(f"{DELIVERY_PATH}/client/bin/rep_subject_credentials password pub.pem priv.pem ", shell=True) + process.wait() + assert os.path.exists(os.path.join(FILES_PATH, 'pub.pem')) and os.path.exists(os.path.join(FILES_PATH, 'priv.pem')) + + +def test_rep_create_org(): + # Test the rep_create_org command + process = subprocess.Popen(f"{DELIVERY_PATH}/client/bin/rep_create_org org1 username name email@org.com pub.pem", shell=True) + process.wait() + assert process.returncode == 0 + + +def test_rep_list_orgs(): + # Test the list_orgs command + process = subprocess.Popen(f"{DELIVERY_PATH}/client/bin/rep_list_orgs", shell=True) + process.wait() + assert process.returncode == 0 + + +def test_rep_create_session(): + # Test the rep_create_session command + process = subprocess.Popen(f"{DELIVERY_PATH}/client/bin/rep_create_session org1 username password pub.pem session.json", shell=True) + process.wait() + assert process.returncode == 0 + + +def test_rep_list_subjects(): + #Test the rep_list_subjects command + process = subprocess.Popen(f"{DELIVERY_PATH}/client/bin/rep_list_subjects session.json", shell=True) + process.wait() + assert process.returncode == 0 + +def test_rep_add_subject(): + # Test the rep_subject_create command + process = subprocess.Popen(f"{DELIVERY_PATH}/client/bin/rep_subject_credentials password pub_extra.pem priv_extra.pem ", shell=True) + process.wait() + assert os.path.exists(os.path.join(FILES_PATH, 'pub_extra.pem')) and os.path.exists(os.path.join(FILES_PATH, 'priv_extra.pem')) + + process = subprocess.Popen(f"{DELIVERY_PATH}/client/bin/rep_add_subject session.json username2 name2 name2@any.com pub_extra.pem", shell=True) + process.wait() + assert process.returncode == 0 + + +def test_rep_suspend_subject(): + # Test the rep_suspend_subject command + process = subprocess.Popen(f"{DELIVERY_PATH}/client/bin/rep_suspend_subject session.json username2", shell=True) + process.wait() + assert process.returncode == 0 + + +def test_rep_activate_subject(): + # Test the rep_activate_subject command + process = subprocess.Popen(f"{DELIVERY_PATH}/client/bin/rep_activate_subject session.json username2", shell=True) + process.wait() + assert process.returncode == 0 + + +def test_rep_add_doc(): + # Test the rep_add_doc command + process = subprocess.Popen(f"dd if=/dev/urandom of={FILES_PATH}test.txt bs=1024 count=1000 ", shell=True) + process.wait() + assert process.returncode == 0 + + process = subprocess.Popen(f"{DELIVERY_PATH}/client/bin/rep_add_doc session.json doc test.txt ", shell=True) + process.wait() + assert process.returncode == 0 + +metadata = {} + +def test_rep_get_doc_metadata(): + # Test the rep_get_doc_metadata command + global metadata + process = subprocess.Popen(f"{DELIVERY_PATH}/client/bin/rep_get_doc_metadata session.json doc", shell=True, stdout=subprocess.PIPE, stderr=subprocess.PIPE, text=True) + process.wait() + stdout, stderr = process.communicate() + metadata = json.loads(stdout) + assert process.returncode == 0 + +def test_rep_get_file(): + # Test the rep_get_file command + process = subprocess.Popen(f"{DELIVERY_PATH}/client/bin/rep_get_file {metadata['file_handle']} file.txt ", shell=True) + process.wait() + assert process.returncode == 0 + +def test_decrypt_file(): + # Test the rep_decrypt_file command + process = subprocess.Popen(f"{DELIVERY_PATH}/client/bin/rep_decrypt_file file.txt '{json.dumps(metadata)}'", shell=True) + process.wait() + assert process.returncode == 0 + +def test_rep_get_doc_file(): + # Test the rep_get_doc_file + process = subprocess.Popen(f"{DELIVERY_PATH}/client/bin/rep_get_doc_file session.json doc ", shell=True) + process.wait() + assert process.returncode == 0 + +def test_rep_delete_doc(): + # Test the rep_get_doc_file + process = subprocess.Popen(f"{DELIVERY_PATH}/client/bin/rep_delete_doc session.json doc ", shell=True) + process.wait() + assert process.returncode == 0 + + + + + + diff --git a/delivery2/lib/asymmetric_functs.py b/delivery2/lib/asymmetric_functs.py new file mode 100644 index 0000000..1c0e7c1 --- /dev/null +++ b/delivery2/lib/asymmetric_functs.py @@ -0,0 +1,142 @@ +import sys, os + +from cryptography.hazmat.primitives import serialization, hashes +from cryptography.hazmat.primitives.asymmetric import rsa, padding +from cryptography.hazmat.primitives.kdf.pbkdf2 import PBKDF2HMAC +from cryptography.hazmat.primitives.ciphers import Cipher, algorithms, modes +from cryptography.hazmat.backends import default_backend + +# ----------------- +# encrypt functions +# ----------------- + +# function to generate a 256-bit symmetric key +def generate_symmetric_key(): + return os.urandom(32) + + +# function to encrypt data using a symmetric key +def encrypt_symmetric(key, plain_text): + # generate a random IV + iv = os.urandom(16) + + # cipher the data using AES in CFB mode + cipher = Cipher(algorithms.AES(key), modes.CFB(iv), backend=default_backend()) + encryptor = cipher.encryptor() + ciphertext = encryptor.update(plain_text) + encryptor.finalize() + + return iv + ciphertext + + +# function that calls and combines the symmetric and asymmetric encryption +def encrypt_hybrid(public_key, plaintext): + # generate a random symmetric key + symmetric_key = generate_symmetric_key() + + encrypted_data = encrypt_symmetric(symmetric_key, plaintext) + + # encrypt the symmetric key with the public key + encrypted_symmetric_key = public_key.encrypt( + symmetric_key, + padding.OAEP( + mgf=padding.MGF1(algorithm=hashes.SHA256()), + algorithm=hashes.SHA256(), + label=None + ) + ) + + # combine the symmetric key and the encrypted data + return encrypted_symmetric_key + encrypted_data + + +# main function to encrypt the file +def encrypt_file(public_key, original_file, encrypted_file): + with open(original_file, 'rb') as f: + plaintext = f.read() + + encrypted_content = encrypt_hybrid(public_key, plaintext) + + with open(encrypted_file, 'wb') as f: + f.write(encrypted_content) + + +# function to load a public key from a file +def load_public_key(file): + with open(file, 'rb') as key_file: + public_key = serialization.load_pem_public_key( + key_file.read(), + ) + public_key_pem = public_key.public_bytes( + encoding=serialization.Encoding.PEM, + format=serialization.PublicFormat.SubjectPublicKeyInfo + ).decode('utf-8') + + return public_key_pem + +# ----------------- +# decrypt functions +# ----------------- + +# function to decrypt data using a symmetric key +def decrypt_symmetric(key, ciphertext): + # generate a random IV + iv = ciphertext[:16] + + # decipher the data using AES in CFB mode + ciphertext = ciphertext[16:] + cipher = Cipher(algorithms.AES(key), modes.CFB(iv), backend=default_backend()) + decryptor = cipher.decryptor() + + return decryptor.update(ciphertext) + decryptor.finalize() + + +# function that calls and combines the symmetric and asymmetric decryption +def decrypt_hybrid(private_key, encrypted_data): + # extract the encrypted symmetric key and the encrypted data (remember that the data is symmetric + asymmetric) + encrypted_symmetric_key = encrypted_data[:private_key.key_size // 8] + encrypted_data = encrypted_data[private_key.key_size // 8:] + + # decrypt the symmetric key using the RSA private key + symmetric_key = private_key.decrypt( + encrypted_symmetric_key, + padding.OAEP( + mgf=padding.MGF1(algorithm=hashes.SHA256()), + algorithm=hashes.SHA256(), + label=None + ) + ) + + # decrypt the data using the decrypted symmetric key + return decrypt_symmetric(symmetric_key, encrypted_data) + + +# main function to decrypt the file +def decrypt_file(private_key, encrypted_file, decrypted_file=None): + with open(encrypted_file, 'rb') as f: + encrypted_content = f.read() + + decrypted_content = decrypt_hybrid(private_key, encrypted_content) + + if decrypted_file is None: + return decrypted_content + else: + with open(decrypted_file, 'wb') as f: + f.write(decrypted_content) + + +# function to load a private key from a file +def load_private_key(file, passwd=None): + if passwd is not None: + passwd = passwd.encode('utf-8') + + try: + with open(file, 'rb') as key_file: + private_key = serialization.load_pem_private_key( + key_file.read(), + password=passwd, + ) + except ValueError as e: + raise ValueError("Error: The password is not valid.") from e + + + return private_key diff --git a/delivery2/lib/digest.py b/delivery2/lib/digest.py new file mode 100644 index 0000000..5e062ae --- /dev/null +++ b/delivery2/lib/digest.py @@ -0,0 +1,7 @@ +import cryptography.hazmat.primitives.hashes + + +def get_hash(data): + digest = cryptography.hazmat.primitives.hashes.Hash(cryptography.hazmat.primitives.hashes.SHA256()) + digest.update(data) + return digest.finalize().hex() \ No newline at end of file diff --git a/delivery2/lib/key_pair.py b/delivery2/lib/key_pair.py new file mode 100644 index 0000000..c0d28fa --- /dev/null +++ b/delivery2/lib/key_pair.py @@ -0,0 +1,34 @@ +import sys + +from cryptography.hazmat.primitives import serialization, hashes +from cryptography.hazmat.primitives.asymmetric import rsa, padding + + +def generate_key_pair(pub_name, priv_name, key_size, passwd=None): + + private_key = rsa.generate_private_key( + public_exponent=65537, + key_size=int(key_size) + ) + public_key = private_key.public_key() + + with open(pub_name, 'wb') as f: + f.write(public_key.public_bytes( + encoding=serialization.Encoding.PEM, + format=serialization.PublicFormat.SubjectPublicKeyInfo + )) + + if not passwd: + with open(priv_name, 'wb') as f: + f.write(private_key.private_bytes( + encoding=serialization.Encoding.PEM, + format=serialization.PrivateFormat.TraditionalOpenSSL, + encryption_algorithm=serialization.NoEncryption() + )) + else: + with open(priv_name, 'wb') as f: + f.write(private_key.private_bytes( + encoding=serialization.Encoding.PEM, + format=serialization.PrivateFormat.TraditionalOpenSSL, + encryption_algorithm=serialization.BestAvailableEncryption(passwd.encode()) + )) diff --git a/delivery2/lib/symmetric_encryption.py b/delivery2/lib/symmetric_encryption.py new file mode 100644 index 0000000..094e6a3 --- /dev/null +++ b/delivery2/lib/symmetric_encryption.py @@ -0,0 +1,46 @@ +import os +from cryptography.hazmat.primitives.kdf.pbkdf2 import PBKDF2HMAC +from cryptography.hazmat.primitives import hashes +from cryptography.hazmat.primitives.ciphers import Cipher, algorithms, modes +from cryptography.hazmat.backends import default_backend + +# Function to encrypt a file using a salt +def encrypt_file(input_file, output_file=None): + key = os.urandom(16) + iv = os.urandom(16) + + cipher = Cipher(algorithms.AES(key), modes.CFB(iv)) + encryptor = cipher.encryptor() + + with open(input_file, 'rb') as f: + plaintext = f.read() + + ciphertext = encryptor.update(plaintext) + encryptor.finalize() + ciphertext = iv + ciphertext + + if output_file is not None: + with open(output_file, 'wb') as f: + f.write(ciphertext) + + print(iv.hex()) + + return key, ciphertext, iv + + +# Function to decrypt a file +def decrypt_file(nonce, key, input_file, output_file=None): + with open(input_file, 'rb') as f: + encrypted_data = f.read() + + ciphertext = encrypted_data + + cipher = Cipher(algorithms.AES(key), modes.CFB(nonce)) + decryptor = cipher.decryptor() + + plaintext = decryptor.update(ciphertext) + decryptor.finalize() + + if output_file is not None: + with open(output_file, 'wb') as f: + f.write(plaintext) + + return plaintext.hex() diff --git a/delivery2/lib/tests/test_digest.py b/delivery2/lib/tests/test_digest.py new file mode 100644 index 0000000..3800eaf --- /dev/null +++ b/delivery2/lib/tests/test_digest.py @@ -0,0 +1,36 @@ +import os + +from digest import * + +def test_equal_string(): + string_one = "Hello, World!" + string_two = "Hello, World!" + + assert get_hash(bytes(string_one, 'utf-8')) == get_hash(bytes(string_two, 'utf-8')) + + +def test_diff_string(): + string_one = "Hello, World!" + string_two = "Hello, World" + + assert get_hash(bytes(string_one, 'utf-8')) != get_hash(bytes(string_two, 'utf-8')) + + +def test_equal_file(): + # create equal files + os.system("dd if=/dev/zero of=test.txt bs=1024 count=1000 >/dev/null 2>&1") + os.system("dd if=/dev/zero of=test2.txt bs=1024 count=1000 >/dev/null 2>&1") + + assert get_hash(open("test.txt", "rb").read()) == get_hash(open("test2.txt", "rb").read()) + os.remove("test.txt") + os.remove("test2.txt") + + +def test_diff_file(): + # create different files + os.system("dd if=/dev/urandom of=test.txt bs=1024 count=1000 >/dev/null 2>&1") + os.system("dd if=/dev/urandom of=test2.txt bs=1024 count=1000 >/dev/null 2>&1") + + assert get_hash(open("test.txt", "rb").read()) != get_hash(open("test2.txt", "rb").read()) + os.remove("test.txt") + os.remove("test2.txt") \ No newline at end of file diff --git a/delivery2/lib/tests/test_encryption.py b/delivery2/lib/tests/test_encryption.py new file mode 100644 index 0000000..9329554 --- /dev/null +++ b/delivery2/lib/tests/test_encryption.py @@ -0,0 +1,186 @@ +import os + +from asymmetric_functs import * +from key_pair import * + +def test_encryption_no_pwd(): + # create a file to encrypt + with open("test.txt", "w") as f: + f.write("Hello, World!") + + # generate a key pair + generate_key_pair('public.pem', 'private.pem', '2048') + + # load the public and private keys + public_key = load_public_key("public.pem") + private_key = load_private_key("private.pem") + + # encrypt the file + encrypt_file(public_key, "test.txt", "test.enc") + + # decrypt the file + decrypt_file(private_key, "test.enc", "test.dec") + + # check that the decrypted file is the same as the original + with open("test.dec", "r") as f: + assert f.read() == "Hello, World!" + + # cleanup + os.remove("test.txt") + os.remove("test.enc") + os.remove("test.dec") + os.remove("public.pem") + os.remove("private.pem") + + +def test_encryption_with_pwd(): + # create a file to encrypt + with open("test.txt", "w") as f: + f.write("Hello, World!") + + # generate a key pair + generate_key_pair('public.pem', 'private.pem', '2048', 'password') + + # load the public and private keys + public_key = load_public_key("public.pem") + private_key = load_private_key("private.pem", 'password') + + # encrypt the file + encrypt_file(public_key, "test.txt", "test.enc") + + # decrypt the file + decrypt_file(private_key, "test.enc", "test.dec") + + # check that the decrypted file is the same as the original + with open("test.dec", "r") as f: + assert f.read() == "Hello, World!" + + # remove the files + os.remove("test.txt") + os.remove("test.enc") + os.remove("test.dec") + os.remove("public.pem") + os.remove("private.pem") + + +def test_load_private_key_wrong_pwd(): + # generate a key pair + generate_key_pair('public.pem', 'private.pem', '2048', 'password') + + # try to load the private key with the wrong password + try: + load_private_key("private.pem", 'wrong_password') + except ValueError as e: + assert str(e) == "Error: The password is not valid." + + +def test_1mb_file_with_pwd(): + # create a 1mb file to encrypt + os.system("dd if=/dev/urandom of=test.txt bs=1024 count=1000 >/dev/null 2>&1") + + # generate a key pair + generate_key_pair('public.pem', 'private.pem', '2048', 'password') + + # load the public and private keys + public_key = load_public_key("public.pem") + private_key = load_private_key("private.pem", 'password') + + # encrypt the file + encrypt_file(public_key, "test.txt", "test.enc") + + # decrypt the file + decrypt_file(private_key, "test.enc", "test.dec") + + # check that the decrypted file is the same as the original + assert open("test.txt", "rb").read() == open("test.dec", "rb").read() + + # remove the files + os.remove("test.txt") + os.remove("test.enc") + os.remove("test.dec") + os.remove("public.pem") + os.remove("private.pem") + + +def test_1mb_file_no_pwd(): + # create a 1mb file to encrypt + os.system("dd if=/dev/urandom of=test.txt bs=1024 count=1000 >/dev/null 2>&1") + + # generate a key pair + generate_key_pair('public.pem', 'private.pem', '2048') + + # load the public and private keys + public_key = load_public_key("public.pem") + private_key = load_private_key("private.pem") + + # encrypt the file + encrypt_file(public_key, "test.txt", "test.enc") + + # decrypt the file + decrypt_file(private_key, "test.enc", "test.dec") + + # check that the decrypted file is the same as the original + assert open("test.txt", "rb").read() == open("test.dec", "rb").read() + + # remove the files + os.remove("test.txt") + os.remove("test.enc") + os.remove("test.dec") + os.remove("public.pem") + os.remove("private.pem") + + +def test_100mb_file_with_pwd(): + # create a 100mb file to encrypt + os.system("dd if=/dev/urandom of=test.txt bs=1024 count=100000 >/dev/null 2>&1") + + # generate a key pair + generate_key_pair('public.pem', 'private.pem', '2048', 'password') + + # load the public and private keys + public_key = load_public_key("public.pem") + private_key = load_private_key("private.pem", 'password') + + # encrypt the file + encrypt_file(public_key, "test.txt", "test.enc") + + # decrypt the file + decrypt_file(private_key, "test.enc", "test.dec") + + # check that the decrypted file is the same as the original + assert open("test.txt", "rb").read() == open("test.dec", "rb").read() + + # remove the files + os.remove("test.txt") + os.remove("test.enc") + os.remove("test.dec") + os.remove("public.pem") + os.remove("private.pem") + + +def test_100mb_file_no_pwd(): + # create a 100mb file to encrypt + os.system("dd if=/dev/urandom of=test.txt bs=1024 count=100000 >/dev/null 2>&1") + + # generate a key pair + generate_key_pair('public.pem', 'private.pem', '2048') + + # load the public and private keys + public_key = load_public_key("public.pem") + private_key = load_private_key("private.pem") + + # encrypt the file + encrypt_file(public_key, "test.txt", "test.enc") + + # decrypt the file + decrypt_file(private_key, "test.enc", "test.dec") + + # check that the decrypted file is the same as the original + assert open("test.txt", "rb").read() == open("test.dec", "rb").read() + + # remove the files + os.remove("test.txt") + os.remove("test.enc") + os.remove("test.dec") + os.remove("public.pem") + os.remove("private.pem") diff --git a/delivery2/server/app.py b/delivery2/server/app.py new file mode 100644 index 0000000..fc669fb --- /dev/null +++ b/delivery2/server/app.py @@ -0,0 +1,52 @@ +import os +import sqlalchemy.exc +from flask import Flask, request, jsonify +from routes import org_bp, user_bp, file_bp +from database import db_connection, db +from models import Organization, User, File, Session + +app = Flask(__name__) +app.config["SQLALCHEMY_DATABASE_URI"] = "sqlite:///database.db" +app.config["SQLALCHEMY_TRACK_MODIFICATIONS"] = True +app.config["SQLALCHEMY_AUTOCOMMIT"] = False +app.config["SQLALCHEMY_AUTOFLUSH"] = False +db_connection.init_app(app) +with app.app_context(): + try: + db_connection.session.query(Session).delete() + db_connection.session.commit() + except sqlalchemy.exc.OperationalError: + pass + db_connection.create_all() + +app.register_blueprint(org_bp, url_prefix="/org") +app.register_blueprint(user_bp, url_prefix="/user") +app.register_blueprint(file_bp, url_prefix="/file") + + +@app.route("/", methods=["GET"]) +def index(): + return jsonify({"message": "Welcome to the API"}), 200 + + +@app.route("/reset", methods=["POST"]) +def reset(): + password = request.json["password"] + if password != "123": + return jsonify({"error": "Invalid password"}), 403 + try: + with app.app_context(): + db_connection.drop_all() + db_connection.create_all() + repos = os.path.join(os.path.dirname(os.path.abspath(__file__)), "repository") + for repo in os.listdir(repos): + if os.path.isdir(os.path.join(repos, repo)): + for file in os.listdir(os.path.join(repos, repo)): + os.remove(os.path.join(repos, repo, file)) + os.rmdir(os.path.join(repos, repo)) + except sqlalchemy.exc.OperationalError: + return jsonify({"error": "Database error"}), 500 + return jsonify({"message": "Database reset"}), 200 + +if __name__ == "__main__": + app.run(debug=True) \ No newline at end of file diff --git a/delivery2/server/database.py b/delivery2/server/database.py new file mode 100644 index 0000000..06dd911 --- /dev/null +++ b/delivery2/server/database.py @@ -0,0 +1,4 @@ +from flask_sqlalchemy import SQLAlchemy + +db_connection = SQLAlchemy() +db = db_connection.session \ No newline at end of file diff --git a/delivery2/server/models/__init__.py b/delivery2/server/models/__init__.py new file mode 100644 index 0000000..91a825f --- /dev/null +++ b/delivery2/server/models/__init__.py @@ -0,0 +1,4 @@ +from .user import * +from .org import * +from .file import * +from .session import * \ No newline at end of file diff --git a/delivery2/server/models/file.py b/delivery2/server/models/file.py new file mode 100644 index 0000000..a7fc99f --- /dev/null +++ b/delivery2/server/models/file.py @@ -0,0 +1,32 @@ +from database import db_connection + + +class File(db_connection.Model): + __tablename__ = 'files' + + id = db_connection.Column(db_connection.Integer, primary_key=True, index=True) + file_handle = db_connection.Column(db_connection.String, unique=True, nullable=True) + document_handle = db_connection.Column(db_connection.String, unique=True, nullable=False) + name = db_connection.Column(db_connection.String, nullable=False) + created_at = db_connection.Column(db_connection.Integer, nullable=False) + key = db_connection.Column(db_connection.String, nullable=False) + alg = db_connection.Column(db_connection.String, nullable=False) + nonce = db_connection.Column(db_connection.String, nullable=False) + org_id = db_connection.Column(db_connection.Integer, db_connection.ForeignKey('organizations.id'), nullable=False) + creator_id = db_connection.Column(db_connection.Integer, db_connection.ForeignKey('users.id'), nullable=False) + org = db_connection.relationship('Organization', backref=db_connection.backref('org_files', uselist=False)) + creator = db_connection.relationship('User', backref=db_connection.backref('created_files', uselist=False)) + + def to_dict(self): + return { + "id": self.id, + "file_handle": self.file_handle, + "document_handle": self.document_handle, + "name": self.name, + "created_at": self.created_at, + "key": self.key, + "alg": self.alg, + "nonce": self.nonce, + "org": {"id": self.org.id, "name": self.org.name}, + "creator": {"id": self.creator.id, "username": self.creator.username} + } \ No newline at end of file diff --git a/delivery2/server/models/org.py b/delivery2/server/models/org.py new file mode 100644 index 0000000..2374eec --- /dev/null +++ b/delivery2/server/models/org.py @@ -0,0 +1,23 @@ +from database import db_connection + +class Organization(db_connection.Model): + __tablename__ = 'organizations' + + id = db_connection.Column(db_connection.Integer, primary_key=True, index=True) + name = db_connection.Column(db_connection.String, unique=True, index=True, nullable=False) + users = db_connection.Column(db_connection.JSON, nullable=False, default=dict) + users_id = db_connection.Column(db_connection.Integer, db_connection.ForeignKey('users.id')) + manager = db_connection.relationship('User', backref=db_connection.backref('owned_organization', uselist=False)) + + def to_dict(self): + return { + "id": self.id, + "name": self.name, + "manager": self.manager.to_dict(), + "users": [{"id": user_id, "user_data": { + "username": user_data["username"], + "full_name": user_data["full_name"], + "email": user_data["email"], + "status": user_data["status"] + }} for user_id, user_data in self.users.items()], + } \ No newline at end of file diff --git a/delivery2/server/models/session.py b/delivery2/server/models/session.py new file mode 100644 index 0000000..5f128ab --- /dev/null +++ b/delivery2/server/models/session.py @@ -0,0 +1,21 @@ +from database import db_connection + +class Session(db_connection.Model): + __tablename__ = 'sessions' + + id = db_connection.Column(db_connection.Integer, primary_key=True) + user_id = db_connection.Column(db_connection.Integer, db_connection.ForeignKey('users.id')) + org_id = db_connection.Column(db_connection.Integer, db_connection.ForeignKey('organizations.id')) + token = db_connection.Column(db_connection.String(255), unique=True) + created_at = db_connection.Column(db_connection.DateTime, server_default=db_connection.func.now()) + updated_at = db_connection.Column(db_connection.DateTime, server_default=db_connection.func.now(), server_onupdate=db_connection.func.now()) + + def to_dict(self): + return { + "id": self.id, + "user_id": self.user_id, + "org_id": self.org_id, + "token": self.token, + "created_at": self.created_at, + "updated_at": self.updated_at + } diff --git a/delivery2/server/models/user.py b/delivery2/server/models/user.py new file mode 100644 index 0000000..3325839 --- /dev/null +++ b/delivery2/server/models/user.py @@ -0,0 +1,22 @@ +from flask_sqlalchemy import SQLAlchemy +from database import db_connection + + +class User(db_connection.Model): + __tablename__ = 'users' + + id = db_connection.Column(db_connection.Integer, primary_key=True, index=True) + username = db_connection.Column(db_connection.String, unique=True, index=True, nullable=False) + full_name = db_connection.Column(db_connection.String, nullable=False) + email = db_connection.Column(db_connection.String, unique=True, index=True, nullable=False) + public_keys = db_connection.Column(db_connection.JSON, nullable=False, default=dict) + orgs = db_connection.Column(db_connection.JSON, nullable=False, default=dict) + + def to_dict(self): + return { + "id": self.id, + "username": self.username, + "full_name": self.full_name, + "email": self.email, + "orgs": [{"id": org_id, "name": org_data["name"], "status": org_data["status"]} for org_id, org_data in self.orgs.items()], + } \ No newline at end of file diff --git a/delivery2/server/repository/.gitkeep b/delivery2/server/repository/.gitkeep new file mode 100644 index 0000000..e69de29 diff --git a/delivery2/server/requirements.txt b/delivery2/server/requirements.txt new file mode 100644 index 0000000..f78b456 --- /dev/null +++ b/delivery2/server/requirements.txt @@ -0,0 +1,3 @@ +cryptography +flask +flask_sqlalchemy \ No newline at end of file diff --git a/delivery2/server/routes/__init__.py b/delivery2/server/routes/__init__.py new file mode 100644 index 0000000..295cf24 --- /dev/null +++ b/delivery2/server/routes/__init__.py @@ -0,0 +1,3 @@ +from .org import org_bp +from .user import user_bp +from .file import file_bp \ No newline at end of file diff --git a/delivery2/server/routes/file.py b/delivery2/server/routes/file.py new file mode 100644 index 0000000..5eb08a6 --- /dev/null +++ b/delivery2/server/routes/file.py @@ -0,0 +1,190 @@ +import json + +from flask import Blueprint, request, jsonify, send_file, Response + +import utils +from services import FileService, OrganizationService, UserService, SessionService + +file_bp = Blueprint("file", __name__) +upload_service = FileService() + + +@file_bp.route("/get//content", methods=["GET"]) +def file_get_content(file_handle: str): + file = FileService.get_file_by_file_handle(file_handle) + if not file: + return jsonify({"error": "File not found"}), 404 + + file_content = FileService.get_file_content(file) + return send_file(file_content, as_attachment=True, download_name=file.name) + + +@file_bp.route("/get//metadata", methods=["GET"]) +def file_get_metadata(document_handle: str): + session_token = request.headers.get("Authorization") + session = SessionService.validate_session(session_token) + if isinstance(session, tuple): + return session + + org = OrganizationService.get_organization(session.org_id) + if not org: + return jsonify({"error": "Organization not found"}), 404 + + file = FileService.get_file_by_document_handle(document_handle) + if not file: + return jsonify({"error": "File not found"}), 404 + + return jsonify(file.to_dict()) + + +@file_bp.route("/upload/metadata", methods=["POST"]) +def file_upload_metadata(): + session_token = request.headers.get("Authorization") + print(session_token) + session = SessionService.validate_session(session_token) + if isinstance(session, tuple): + return session + + data = request.json + if type(data) is str: + data = json.loads(data) + if "document_name" not in data or "key" not in data or "alg" not in data or "nonce" not in data: + return jsonify({"error": "Missing required fields"}), 400 + + org = OrganizationService.get_organization(session.org_id) + if not org: + return jsonify({"error": "Organization not found"}), 404 + + user = UserService.get_user(session.user_id) + if not user: + return jsonify({"error": "User not found"}), 404 + + file = upload_service.create_file(session.token, org, user, data["document_name"], data["key"], data["alg"], data["nonce"]) + return jsonify(file.to_dict()), 201 + + +@file_bp.route("/upload/content", methods=["POST"]) +def file_upload_content(): + session_token = request.headers.get("Authorization") + if not session_token: + return jsonify({"error": "No session token"}), 400 + + session = SessionService.validate_session(session_token) + if isinstance(session, tuple): + return session + + if "file" not in request.files: + return jsonify({"error": "No file data"}), 400 + + file = request.files.get("file") + if file.filename == "": + return jsonify({"error": "No file selected for uploading"}), 400 + + if not file: + return jsonify({"error": "Invalid file data"}), 400 + + file_data = utils.get_hex_from_temp_file(file.stream) + + file_sum = request.headers.get("File-Checksum") + if not file_sum: + return jsonify({"error": "No file checksum provided"}), 400 + + if file_sum != str(utils.get_hash(file_data)): + return jsonify({"error": "File checksum mismatch"}), 400 + + file = upload_service.write_file(session_token, file_sum, file_data) + if isinstance(file, tuple): + return file + + return jsonify(file.to_dict()), 201 + + +@file_bp.route("/list", methods=["GET"]) +def file_list(): + session_token = request.headers.get("Authorization") + if not session_token: + return jsonify({"error": "No session token"}), 400 + + session = SessionService.validate_session(session_token) + if isinstance(session, tuple): + return session + + + data = request.json + if type(data) is str: + data = json.loads(data) + + org = OrganizationService.get_organization(session.org_id) + if not org: + return jsonify({"error": "Organization not found"}), 404 + + if "datetime" in data: + try: + datetime_value = int(data["datetime"]["value"]) + datetime_relation = data["datetime"]["relation"] + except ValueError: + return jsonify({"error": "Invalid datetime value"}), 400 + + if "username" in data: + user = UserService.get_user_by_username(data["username"]) + if not user: + return jsonify({"error": "User not found"}), 404 + files = FileService.list_files_in_org(org) + return jsonify([file.to_dict() for file in files if file.creator_id == user.id and ( + utils.check_valid_time(file.created_at, datetime_value, datetime_relation) + if "datetime" in data else True + )]) + + files = FileService.list_files_in_org(org) + return jsonify([file.to_dict() for file in files if (utils.check_valid_time(file.created_at, datetime_value, datetime_relation) if "datetime" in data else True)]) + + +@file_bp.route("/delete/", methods=["POST"]) +def file_delete(document_handle: str): + session_token = request.headers.get("Authorization") + if not session_token: + return jsonify({"error": "No session token"}), 400 + + session = SessionService.validate_session(session_token) + if isinstance(session, tuple): + return session + + org = OrganizationService.get_organization(session.org_id) + if not org: + return jsonify({"error": "Organization not found"}), 404 + + file = FileService.get_file_by_document_handle(document_handle) + if not file: + return jsonify({"error": "File not found"}), 404 + + if file.creator_id != session.user_id: + return jsonify({"error": "Not authorized to delete file"}), 403 + + file = FileService.delete_file(file) + return jsonify(file.to_dict()) + + +################################################ + + +@file_bp.route("/create_dummy", methods=["POST"]) +def file_create_dummy(): + session_token = request.headers.get("Authorization") + if not session_token: + return jsonify({"error": "No session token"}), 400 + + session = SessionService.validate_session(session_token) + + if isinstance(session, tuple): + return session + + org = OrganizationService.get_organization(session.org_id) + if not org: + return jsonify({"error": "Organization not found"}), 404 + + user = UserService.get_user(session.user_id) + if not user: + return jsonify({"error": "User not found"}), 404 + + file = FileService.create_dummy_file(org, user) + return jsonify(file.to_dict()), 201 \ No newline at end of file diff --git a/delivery2/server/routes/org.py b/delivery2/server/routes/org.py new file mode 100644 index 0000000..586952b --- /dev/null +++ b/delivery2/server/routes/org.py @@ -0,0 +1,32 @@ +import json +from flask import Blueprint, request, jsonify +from services import OrganizationService + +org_bp = Blueprint("org", __name__) + +@org_bp.route("/create", methods=["POST"]) +def org_create(): + data = request.json + if type(data) is str: + data = json.loads(data) + if "name" not in data or "username" not in data or "full_name" not in data or "email" not in data or "public_key" not in data: + return jsonify({"error": "Missing required fields"}), 400 + + existing_org = OrganizationService.get_organization_by_name(data["name"]) + if existing_org: + return jsonify({"error": "Organization already exists"}), 400 + + org = OrganizationService.create_organization( + name=data["name"], + username=data["username"], + full_name=data["full_name"], + email=data["email"], + public_key=data["public_key"] + ) + + return jsonify(org.to_dict()), 201 + +@org_bp.route("/list", methods=["GET"]) +def org_list(): + orgs = OrganizationService.list_organizations() + return jsonify([org.to_dict() for org in orgs]) diff --git a/delivery2/server/routes/user.py b/delivery2/server/routes/user.py new file mode 100644 index 0000000..4efea2b --- /dev/null +++ b/delivery2/server/routes/user.py @@ -0,0 +1,152 @@ +import json +import utils +from flask import Blueprint, request, jsonify +from services import UserService, SessionService, OrganizationService + +user_bp = Blueprint("user", __name__) + +@user_bp.route("/login", methods=["POST"]) +def user_login(): + data = request.json + if type(data) is str: + data = json.loads(data) + + if "username" not in data or "org" not in data: + return jsonify({"error": "Missing required fields"}), 400 + + user = UserService.get_user_by_username(data["username"]) + if not user: + return jsonify({"error": "User not found"}), 404 + + org = OrganizationService.get_organization_by_name(data["org"]) + if not org: + return jsonify({"error": "Organization not found"}), 404 + + session = SessionService.create_session(user, org) + return jsonify(session.to_dict()), 201 + + +@user_bp.route("/logout", methods=["POST"]) +def user_logout(): + session_token = request.headers.get("Authorization") + if not session_token: + return jsonify({"error": "No session token"}), 400 + + session = SessionService.get_session(session_token) + if not session: + return jsonify({"error": "Not authenticated"}), 401 + + SessionService.delete_session(session) + return jsonify({"message": "Logged out"}), 200 + + +@user_bp.route("/list", methods=["GET"]) +def user_list(): + session_token = request.headers.get("Authorization") + if not session_token: + return jsonify({"error": "No session token"}), 400 + + session = SessionService.validate_session(session_token) + if isinstance(session, tuple): + return session + + data = request.json + if type(data) is str: + data = json.loads(data) + + org = OrganizationService.get_organization(session.org_id) + if not org: + return jsonify({"error": "Organization not found"}), 404 + + if "username" in data: + user = UserService.get_user_by_username(data["username"]) + if not user: + return jsonify({"error": "User not found"}), 404 + return jsonify(user.to_dict()), 200 + + users = OrganizationService.get_users_in_organization(org) + return jsonify(users), 200 + + +@user_bp.route("/create", methods=["POST"]) +def user_create(): + session_token = request.headers.get("Authorization") + if not session_token: + return jsonify({"error": "No session token"}), 400 + + session = SessionService.validate_session(session_token) + if isinstance(session, tuple): + return session + + data = request.json + if type(data) is str: + data = json.loads(data) + + if "username" not in data or "full_name" not in data or "email" not in data or "public_key" not in data: + return jsonify({"error": "Missing required fields"}), 400 + + org = OrganizationService.get_organization(session.org_id) + if not org: + return jsonify({"error": "Organization not found"}), 404 + + if org.manager.id != session.user_id: + return jsonify({"error": "Not authorized to create users"}), 403 + + user = UserService.get_user_by_username(data["username"]) + if not user: + user = UserService.create_user( + username=data["username"], + full_name=data["full_name"], + email=data["email"], + public_key=data["public_key"], + org=org + ) + + return jsonify(user.to_dict()), 201 + + +@user_bp.route("//suspend", methods=["POST"]) +def user_suspend(username): + session_token = request.headers.get("Authorization") + if not session_token: + return jsonify({"error": "No session token"}), 400 + + session = SessionService.validate_session(session_token) + if isinstance(session, tuple): + return session + + org = OrganizationService.get_organization(session.org_id) + if not org: + return jsonify({"error": "Organization not found"}), 404 + if org.manager.id != session.user_id: + return jsonify({"error": "Not authorized to suspend users"}), 403 + + user = UserService.get_user_by_username(username) + if not user: + return jsonify({"error": "User not found"}), 404 + + return OrganizationService.suspend_user(org, user) + + +@user_bp.route("//activate", methods=["POST"]) +def user_unsuspend(username): + session_token = request.headers.get("Authorization") + if not session_token: + return jsonify({"error": "No session token"}), 400 + + session = SessionService.validate_session(session_token) + if isinstance(session, tuple): + return session + + org = OrganizationService.get_organization(session.org_id) + if not org: + return jsonify({"error": "Organization not found"}), 404 + if org.manager.id != session.user_id: + return jsonify({"error": "Not authorized to unsuspend users"}), 403 + + user = UserService.get_user_by_username(username) + if not user: + return jsonify({"error": "User not found"}), 404 + + return OrganizationService.activate_user(org, user) + diff --git a/delivery2/server/services/__init__.py b/delivery2/server/services/__init__.py new file mode 100644 index 0000000..7ab51d7 --- /dev/null +++ b/delivery2/server/services/__init__.py @@ -0,0 +1,4 @@ +from .orgs import OrganizationService +from .users import UserService +from .files import FileService +from .sessions import SessionService \ No newline at end of file diff --git a/delivery2/server/services/files.py b/delivery2/server/services/files.py new file mode 100644 index 0000000..9bf55f7 --- /dev/null +++ b/delivery2/server/services/files.py @@ -0,0 +1,111 @@ +import os +import io +from datetime import datetime +from typing import List, Type +from flask import jsonify + +from database import db +from models import File, Organization, User +from utils import get_hash + + +class FileService: + def __init__(self): + self.current_requests = {} + + def create_file(self, session_token: str, org: Organization, user: User, file_name: str, key: str, alg: str, nonce: str) -> File: + file = File( + file_handle = None, + document_handle = get_hash(file_name), + name = file_name, + created_at = int(datetime.now().timestamp()), + key = key, + alg = alg, + nonce = nonce, + org_id = org.id, + creator_id = user.id, + org = org, + creator = user + ) + + db.add(file) + db.commit() + db.refresh(file) + self.current_requests[session_token] = file.id + + return file + + + def write_file(self, session_token: str, file_handle: str, file_data: bytes) -> File | tuple: + if session_token not in self.current_requests: + return jsonify({"error": "No file upload request found"}), 400 + + file = db.query(File).filter(File.id == self.current_requests[session_token]).first() + file_path = os.path.join(os.path.join(os.path.dirname(os.path.dirname(os.path.abspath(__file__))), "repository"), file.org.name, file.document_handle) + with open(file_path, "wb") as f: + f.write(file_data) + + file.file_handle = file_handle + db.commit() + db.refresh(file) + + del self.current_requests[session_token] + return file + + + @staticmethod + def create_dummy_file(org: Organization, user: User) -> File: + file = File( + file_handle = "dummy_file", + document_handle = "org/dummy_file.txt", + name = "dummy_file", + created_at = int(datetime.now().timestamp()), + org_id = 1, + creator_id = 1, + org = org, + creator = user + ) + + file_path = os.path.join(os.path.join(os.path.dirname(os.path.dirname(os.path.abspath(__file__))), "repository"), file.document_handle) + with open(file_path, "w") as f: + f.write("Dummy file content") + + db.add(file) + db.commit() + db.refresh(file) + return file + + @staticmethod + def get_file(file_id: int) -> File | None: + return db.query(File).filter(File.id == file_id).first() + + @staticmethod + def get_file_by_document_handle(document_handle: str) -> File | None: + return db.query(File).filter(File.document_handle == document_handle).first() + + @staticmethod + def get_file_by_file_handle(file_handle: str) -> File | None: + return db.query(File).filter(File.file_handle == file_handle).first() + + @staticmethod + def get_file_content(file: File) -> io.BytesIO: + file_path = os.path.join(os.path.join(os.path.dirname(os.path.dirname(os.path.abspath(__file__))), "repository"), file.org.name, file.document_handle) + with open(file_path, "rb") as f: + return io.BytesIO(f.read()) + + @staticmethod + def list_files() -> list[Type[File]]: + return db.query(File).all() + + @staticmethod + def list_files_in_org(org: Organization) -> list[Type[File]]: + return db.query(File).filter(File.org_id == org.id).all() + + @staticmethod + def delete_file(file: File) -> File: + file_path = os.path.join(os.path.join(os.path.dirname(os.path.dirname(os.path.abspath(__file__))), "repository"), file.org.name, file.document_handle) + os.remove(file_path) + file.file_handle = None + db.commit() + db.refresh(file) + return file \ No newline at end of file diff --git a/delivery2/server/services/orgs.py b/delivery2/server/services/orgs.py new file mode 100644 index 0000000..a5e7388 --- /dev/null +++ b/delivery2/server/services/orgs.py @@ -0,0 +1,99 @@ +import os.path + +from database import db +from models import Organization, User +from sqlalchemy.orm.attributes import flag_modified + + +class OrganizationService: + @staticmethod + def create_organization(name: str, username: str, full_name: str, email: str, public_key: str) -> Organization: + from services import UserService + user = UserService().get_user_by_username(username) + if not user: + user = UserService().create_user(username, full_name, email, public_key) + + project_root = os.path.dirname(os.path.dirname(os.path.abspath(__file__))) + repos = os.path.join(project_root, "repository") + if not os.path.exists(os.path.join(repos, name)): + os.mkdir(os.path.join(repos, name)) + + organization = Organization( + name=name, + manager=user, + users={user.id: { + "username": user.username, + "full_name": user.full_name, + "email": user.email, + "status": "active" + }} + ) + + db.add(organization) + db.commit() + db.refresh(organization) + + UserService().add_org_to_user(user, organization) + UserService().add_public_key_to_user(user, organization, public_key) + + return organization + + @staticmethod + def list_organizations(): + return db.query(Organization).all() + + @staticmethod + def get_organization(org_id: int) -> Organization | None: + return db.query(Organization).filter(Organization.id == org_id).first() + + @staticmethod + def get_organization_by_name(name: str) -> Organization | None: + return db.query(Organization).filter(Organization.name == name).first() + + @staticmethod + def get_users_in_organization(org: Organization) -> list[User]: + return db.query(Organization).filter(Organization.id == org.id).first().users + + @staticmethod + def get_user_status(org: Organization, user_id: int) -> str: + return db.query(Organization).filter(Organization.id == org.id).first().users[str(user_id)]["status"] + + @staticmethod + def add_user_to_organization(org: Organization, user: User) -> Organization: + org.users[str(user.id)] = { + "username": user.username, + "full_name": user.full_name, + "email": user.email, + "status": "active" + } + flag_modified(org, "users") + db.commit() + db.refresh(org) + return org + + @staticmethod + def suspend_user(org: Organization, user: User) -> tuple: + if OrganizationService.get_user_status(org, user.id) != "active": + return {"error": "User already suspended"}, 400 + + if org.manager.id == user.id: + return {"error": "Cannot suspend manager"}, 400 + + org.users[str(user.id)]["status"] = "suspended" + flag_modified(org, "users") + db.commit() + db.refresh(org) + + return {"message": "User suspended"}, 200 + + @staticmethod + def activate_user(org: Organization, user: User) -> tuple: + if OrganizationService.get_user_status(org, user.id) != "suspended": + return {"error": "User already active"}, 400 + + org.users[str(user.id)]["status"] = "active" + flag_modified(org, "users") + db.commit() + db.refresh(org) + + return {"message": "User activated"}, 200 diff --git a/delivery2/server/services/sessions.py b/delivery2/server/services/sessions.py new file mode 100644 index 0000000..c322a1f --- /dev/null +++ b/delivery2/server/services/sessions.py @@ -0,0 +1,48 @@ +import secrets +from database import db +from models import Session, User, Organization +from flask import jsonify + + +class SessionService: + @staticmethod + def create_session(user: User, org: Organization) -> Session: + session = Session( + user_id=user.id, + org_id=org.id, + token=secrets.token_hex(128) + ) + db.add(session) + db.commit() + db.refresh(session) + return session + + @staticmethod + def get_session(token: str) -> Session | None: + return db.query(Session).filter(Session.token == token).first() + + @staticmethod + def delete_session(session: Session) -> None: + db.delete(session) + db.commit() + + @staticmethod + def validate_session(token: str) -> tuple | Session: + from services import OrganizationService + + if "Bearer" in token: + token = token.split(" ")[1] + + session = SessionService.get_session(token) + if not session: + return jsonify({"error": "Not authenticated"}), 401 + + org = OrganizationService.get_organization(session.org_id) + if not org: + return jsonify({"error": "Organization not found"}), 404 + + status = OrganizationService.get_user_status(org, session.user_id) + if status != "active": + return jsonify({"error": "User is not active"}), 403 + + return session diff --git a/delivery2/server/services/users.py b/delivery2/server/services/users.py new file mode 100644 index 0000000..2f11bca --- /dev/null +++ b/delivery2/server/services/users.py @@ -0,0 +1,53 @@ +from database import db +from models import User, Organization + + +class UserService: + @staticmethod + def create_user(username: str, full_name: str, email: str, public_key: str, org: Organization = None) -> User: + from services import OrganizationService + user = User( + username=username, + full_name=full_name, + email=email, + public_keys={org.id: public_key} if org else {}, + orgs={org.id: { + "name": org.name, + "status": "active" + }} if org else {} + ) + db.add(user) + db.commit() + db.refresh(user) + if org: + OrganizationService.add_user_to_organization(org, user) + return user + + @staticmethod + def get_user(user_id: int) -> User | None: + return db.query(User).filter(User.id == user_id).first() + + @staticmethod + def get_user_by_username(username: str) -> User | None: + return db.query(User).filter(User.username == username).first() + + @staticmethod + def add_org_to_user(user: User, org: Organization) -> User: + orgs = user.orgs.copy() + orgs[org.id] = { + "name": org.name, + "status": "active" + } + user.orgs = orgs + db.commit() + db.refresh(user) + return user + + @staticmethod + def add_public_key_to_user(user: User, org: Organization, public_key: str) -> User: + public_keys = user.public_keys.copy() + public_keys[org.id] = public_key + user.public_keys = public_keys + db.commit() + db.refresh(user) + return user \ No newline at end of file diff --git a/delivery2/server/tests/file_management.http b/delivery2/server/tests/file_management.http new file mode 100644 index 0000000..167b431 --- /dev/null +++ b/delivery2/server/tests/file_management.http @@ -0,0 +1,106 @@ +### Reset database +POST http://localhost:5000/reset +Content-Type: application/json + +{ + "password": "123" +} + +### Create a new organization +POST http://localhost:5000/org/create +Content-Type: application/json + +{ + "name": "org", + "username": "username", + "full_name": "Full Name", + "email": "user@mail.com", + "public_key": "null" +} + +### Login +POST http://localhost:5000/user/login +Content-Type: application/json + +{ + "username": "username", + "org": "org" +} + +> {% client.global.set("token", response.body["token"]) %} + +### Upload dummy file metadata +POST http://localhost:5000/file/upload/metadata +Content-Type: application/json +Authorization: {{token}} + +{ + "document_name": "dummy_file.txt", + "key": "arfarf", + "alg": "ftgtrg" +} + +> {% client.global.set("document_handle", response.body["document_handle"]) %} + + +#### Upload dummy file content, through send file +#POST http://localhost:5000/file/upload/content +#Content-Type: multipart/form-data; boundary=----WebKitFormBoundary7MA4YWxkTrZu0gW +#Authorization: {{token}} +# +#----WebKitFormBoundary7MA4YWxkTrZu0gW +#Content-Disposition: form-data; name="file"; filename="dummy_file.txt" +#Content-Type: text/plain +# +#This is a dummy file +#----WebKitFormBoundary7MA4YWxkTrZu0gW + +### List files (with no data) +GET http://localhost:5000/file/list +Content-Type: application/json +Authorization: {{token}} + +{} + +### List files by user +GET http://localhost:5000/file/list +Content-Type: application/json +Authorization: {{token}} + +{ + "username": "username" +} + +### List files by datetime +GET http://localhost:5000/file/list +Content-Type: application/json +Authorization: {{token}} + +{ + "datetime": { + "relation": "ot", + "value": "1731863876" + } +} + +### Get file metadata +GET http://localhost:5000/file/get/{{document_handle}}/metadata +Content-Type: application/json +Authorization: {{token}} + +> {% client.global.set("file_handle", response.body["file_handle"]) %} + +### Get file content +GET http://localhost:5000/file/get/{{file_handle}}/content +Content-Type: application/json +Authorization: {{token}} + +### Delete dummy file +POST http://localhost:5000/file/delete/{{document_handle}} +Content-Type: application/json +Authorization: {{token}} + +### Logout +POST http://localhost:5000/user/logout +Content-Type: application/json +Authorization: {{token}} diff --git a/delivery2/server/tests/user_management.http b/delivery2/server/tests/user_management.http new file mode 100644 index 0000000..8bd6a66 --- /dev/null +++ b/delivery2/server/tests/user_management.http @@ -0,0 +1,76 @@ +### Reset database +POST http://localhost:5000/reset +Content-Type: application/json + +{ + "password": "123" +} + +### Create a new organization +POST http://localhost:5000/org/create +Content-Type: application/json + +{ + "name": "org", + "username": "username", + "full_name": "Full Name", + "email": "user@mail.com", + "public_key": "null" +} + +### Login +POST http://localhost:5000/user/login +Content-Type: application/json + +{ + "username": "username", + "org": "org", + "public_key": "null" +} + +> {% client.global.set("token", response.body["token"]) %} + +### List organizations +GET http://localhost:5000/org/list + +### Create a new user +POST http://localhost:5000/user/create +Content-Type: application/json +Authorization: {{token}} + +{ + "username": "newuser", + "full_name": "Full Name", + "email": "newuser@mail.com", + "public_key": "null2" +} + +### List users +GET http://localhost:5000/user/list +Content-Type: application/json +Authorization: {{token}} + +{} + +### Suspend user +POST http://localhost:5000/user/suspend +Content-Type: application/json +Authorization: {{token}} + +{ + "username": "newuser" +} + +### Activate user +POST http://localhost:5000/user/activate +Content-Type: application/json +Authorization: {{token}} + +{ + "username": "newuser" +} + +### Logout +POST http://localhost:5000/user/logout +Content-Type: application/json +Authorization: {{token}} diff --git a/delivery2/server/utils/__init__.py b/delivery2/server/utils/__init__.py new file mode 100644 index 0000000..8eb7741 --- /dev/null +++ b/delivery2/server/utils/__init__.py @@ -0,0 +1,2 @@ +from .checks import check_valid_time +from .hashing import get_hash, get_hex_from_temp_file \ No newline at end of file diff --git a/delivery2/server/utils/checks.py b/delivery2/server/utils/checks.py new file mode 100644 index 0000000..2d8755c --- /dev/null +++ b/delivery2/server/utils/checks.py @@ -0,0 +1,9 @@ +def check_valid_time(time: int, relation_time: int, relation: str) -> bool: + if relation == 'ot': + return time < relation_time + elif relation == 'nt': + return time > relation_time + elif relation == 'eq': + return time == relation_time + else: + raise ValueError('Invalid relation: {}'.format(relation)) diff --git a/delivery2/server/utils/hashing.py b/delivery2/server/utils/hashing.py new file mode 100644 index 0000000..34e26ea --- /dev/null +++ b/delivery2/server/utils/hashing.py @@ -0,0 +1,16 @@ +from tempfile import SpooledTemporaryFile +import cryptography.hazmat.primitives.hashes + + +def get_hash(data): + if isinstance(data, str): + data = data.encode('utf-8') + digest = cryptography.hazmat.primitives.hashes.Hash(cryptography.hazmat.primitives.hashes.SHA256()) + digest.update(data) + return digest.finalize().hex() + + +def get_hex_from_temp_file(temp_file: SpooledTemporaryFile) -> bytes: + temp_file.seek(0) + file_data = temp_file.read() + return file_data