From 6dde4dd0bc5e5f91f89587c75a30c9ef7a24494c Mon Sep 17 00:00:00 2001 From: Christian Cleberg Date: Wed, 6 Nov 2024 23:23:27 -0600 Subject: package as a cli app --- .gitignore | 1 + README.md | 5 +- main.py | 133 ---------------- pyproject.toml | 5 +- requirements.txt | 2 +- src/account.py | 43 ------ src/crypto.py | 45 ------ src/database.py | 125 --------------- src/process.py | 225 --------------------------- src/yoshi_cli.egg-info/PKG-INFO | 198 ------------------------ src/yoshi_cli.egg-info/SOURCES.txt | 11 -- src/yoshi_cli.egg-info/dependency_links.txt | 1 - src/yoshi_cli.egg-info/top_level.txt | 4 - yoshi/__init__.py | 0 yoshi/__main__.py | 3 + yoshi/account.py | 43 ++++++ yoshi/cli.py | 136 +++++++++++++++++ yoshi/crypto.py | 45 ++++++ yoshi/database.py | 125 +++++++++++++++ yoshi/process.py | 226 ++++++++++++++++++++++++++++ yoshi_cli.egg-info/PKG-INFO | 197 ++++++++++++++++++++++++ yoshi_cli.egg-info/SOURCES.txt | 15 ++ yoshi_cli.egg-info/dependency_links.txt | 1 + yoshi_cli.egg-info/entry_points.txt | 2 + yoshi_cli.egg-info/top_level.txt | 1 + 25 files changed, 802 insertions(+), 790 deletions(-) delete mode 100644 main.py delete mode 100644 src/account.py delete mode 100644 src/crypto.py delete mode 100644 src/database.py delete mode 100644 src/process.py delete mode 100644 src/yoshi_cli.egg-info/PKG-INFO delete mode 100644 src/yoshi_cli.egg-info/SOURCES.txt delete mode 100644 src/yoshi_cli.egg-info/dependency_links.txt delete mode 100644 src/yoshi_cli.egg-info/top_level.txt create mode 100644 yoshi/__init__.py create mode 100644 yoshi/__main__.py create mode 100644 yoshi/account.py create mode 100644 yoshi/cli.py create mode 100644 yoshi/crypto.py create mode 100644 yoshi/database.py create mode 100644 yoshi/process.py create mode 100644 yoshi_cli.egg-info/PKG-INFO create mode 100644 yoshi_cli.egg-info/SOURCES.txt create mode 100644 yoshi_cli.egg-info/dependency_links.txt create mode 100644 yoshi_cli.egg-info/entry_points.txt create mode 100644 yoshi_cli.egg-info/top_level.txt diff --git a/.gitignore b/.gitignore index a284724..0f74d83 100644 --- a/.gitignore +++ b/.gitignore @@ -1,6 +1,7 @@ .DS_Store .idea/ .pypirc +build/ dist/ venv/ __pycache__/ diff --git a/README.md b/README.md index f6ae396..8c04013 100644 --- a/README.md +++ b/README.md @@ -34,8 +34,7 @@ To run the script locally, run the following commands: ```bash git clone REPO_URL cd yoshi -pip3 install -r requirements.txt -python3 main.py --help +pip install . ``` # Usage @@ -43,7 +42,7 @@ python3 main.py --help [(Back to top)](#table-of-contents) All commands can be passed to the program with the following template: -`python3 main.py ` +`python3 src/yoshi/cli.py ` ![Yoshi CLI Help](./examples/yoshi-help.png) diff --git a/main.py b/main.py deleted file mode 100644 index 913103d..0000000 --- a/main.py +++ /dev/null @@ -1,133 +0,0 @@ -""" -This script uses argparse to parse command line arguments. - -It imports the required modules and sets up a parser with basic options for demonstration purposes. -""" - -import argparse -import crypto -import database -import process - -if __name__ == '__main__': - parser = argparse.ArgumentParser( - description='Manage your username and passwords via a convenient CLI vault.' - ) - - # Top-level arguments - group_one = parser.add_mutually_exclusive_group() - group_one.add_argument( - '-n', '--new', - help='Create a new account.', - action='store_true' - ) - group_one.add_argument( - '-l', '--list', - help='List all saved accounts.', - action='store_true' - ) - group_one.add_argument( - '-e', '--edit', - help='Edit a saved account.', - action='store_true' - ) - group_one.add_argument( - '-d', '--delete', - help='Delete a saved account.', - action='store_true' - ) - group_one.add_argument( - '--purge', - help=( - 'Purge all accounts and delete the vault. ' - '(Caution: this will irreversibly destroy your data.)' - ), - action='store_true' - ) - group_one.add_argument( - '--encrypt', - help='Encrypt the vault.', - action='store_true' - ) - group_one.add_argument( - '--decrypt', - help='Decrypt the vault.', - action='store_true' - ) - - # Encryption flags - group_two = parser.add_mutually_exclusive_group() - group_two.add_argument( - '-g', '--generate', - help=( - 'When using the --encrypt option, generate a new encryption key.' - ), - action='store_true' - ) - group_two.add_argument( - '-k', '--keyfile', - help='Path to existing key file.', - action='store', - nargs=1, - type=str - ) - - # Edit flags - group_three = parser.add_argument_group() - group_three.add_argument( - '-u', '--uuid', - help=( - 'When using the --edit or --delete options, provide the account UUID.' - ), - action='store', - nargs=1, - type=str - ) - group_three.add_argument( - '-f', '--field', - help=( - 'When using the --edit option, specify the field to edit (integer index).' - ), - action='store', - nargs=1, - type=int - ) - - args = parser.parse_args() - - if args.decrypt: - if args.keyfile: - key = crypto.load_key(args.keyfile[0]) - else: - key = input('Please enter your decryption key: ') - crypto.decrypt(key) - elif args.encrypt: - if args.generate: - key = crypto.generate_key() - print( - 'WRITE THIS KEY DOWN SOMEWHERE SAFE. YOU WILL NOT BE ABLE TO DECRYPT ' - 'YOUR DATA WITHOUT IT!' - ) - print(key.decode()) - print('\n') - else: - if args.keyfile: - key = crypto.load_key(args.keyfile[0]) - else: - key = input('Please enter your encryption key: ') - crypto.encrypt(key) - elif database.check_table(): - if args.new: - process.create_account() - elif args.list: - process.list_accounts() - elif args.edit: - process.edit_account(args.uuid[0], args.field[0]) - elif args.delete: - process.delete_account(args.uuid[0]) - elif args.purge: - process.purge_accounts() - else: - raise TypeError( - 'Please specify a command or use the --help flag for more information.' - ) diff --git a/pyproject.toml b/pyproject.toml index 43c4421..db6182d 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,6 +1,6 @@ [project] name = "yoshi-cli" -version = "0.1.0" +version = "0.1.1" authors = [ { name="Christian Cleberg", email="hello@cleberg.net" }, ] @@ -16,3 +16,6 @@ classifiers = [ [project.urls] Homepage = "https://github.com/ccleberg/yoshi" Issues = "https://github.com/ccleberg/yoshi/issues" + +[project.scripts] +yoshi = "yoshi.cli:yoshi" diff --git a/requirements.txt b/requirements.txt index 5d49f4c..8c8473a 100644 --- a/requirements.txt +++ b/requirements.txt @@ -1,2 +1,2 @@ prettytable~=2.2.0 -cryptography~=3.4.8 \ No newline at end of file +cryptography diff --git a/src/account.py b/src/account.py deleted file mode 100644 index 79fcc13..0000000 --- a/src/account.py +++ /dev/null @@ -1,43 +0,0 @@ -""" -This script imports necessary modules for database interactions. - -Modules imported: - - database: A custom module providing database functionality. -""" - -import database - - -class Account: - """Represents a login account.""" - - def __init__(self, uuid: str, application: str, #pylint: disable=R0913,R0917 - username: str, #pylint: disable=R0913,R0917 - password: str, url: str) -> None: #pylint: disable=R0913,R0917 - self.uuid = uuid - self.application = application - self.username = username - self.password = password - self.url = url - - def display_account(self) -> None: - """Print the account details.""" - print('ID:', self.uuid) - print('Application:', self.application) - print('Username:', self.username) - print('Password:', self.password) - print('URL:', self.url) - - def save_account(self) -> None: - """Save the account details to the database.""" - database.add_account( - self.uuid, self.application, self.username, self.password, self.url) - - def delete_account(self) -> bool: - """Delete the account from the database. - - Returns: - bool: True if the deletion was successful. - """ - database.delete_account(self.uuid) - return True diff --git a/src/crypto.py b/src/crypto.py deleted file mode 100644 index 9b0a423..0000000 --- a/src/crypto.py +++ /dev/null @@ -1,45 +0,0 @@ -""" -This module imports the Fernet symmetric encryption algorithm from the cryptography library. - -It allows for secure encryption and decryption of data using a secret key. -""" - -from cryptography.fernet import Fernet - -VAULT_FILE = 'vault.sqlite' - - -def generate_key() -> bytes: - """Generates a new encryption key.""" - return Fernet.generate_key() - - -def load_key(key_file: str) -> bytes: - """ - Loads an existing encryption key from the file. - - Args: - key_file (str): Path to the key file. - """ - with open(key_file, 'rb') as key: - return key.read() - - -def encrypt(key: bytes, filename: str = VAULT_FILE) -> None: - """Encrypts the data in the specified file using the provided key.""" - f = Fernet(key) - with open(filename, 'rb') as vault: - data = vault.read() - encrypted_data = f.encrypt(data) - with open(filename, 'wb') as vault: - vault.write(encrypted_data) - - -def decrypt(key: bytes, filename: str = VAULT_FILE) -> None: - """Decrypts the data in the specified file using the provided key.""" - f = Fernet(key) - with open(filename, 'rb') as vault: - encrypted_data = vault.read() - decrypted_data = f.decrypt(encrypted_data) - with open(filename, 'wb') as vault: - vault.write(decrypted_data) diff --git a/src/database.py b/src/database.py deleted file mode 100644 index e1e2e78..0000000 --- a/src/database.py +++ /dev/null @@ -1,125 +0,0 @@ -""" -This module provides a basic interface for connecting to and interacting with a SQLite database. -It includes functions for creating connections, executing queries, and retrieving results. -""" - -import sqlite3 -import sys -import os - -VAULT_DECRYPTED = 'vault.sqlite' -VAULT_ENCRYPTED = 'vault.sqlite.aes' - - -def create_table() -> None: - """Create the accounts table within the vault database.""" - db_connection = sqlite3.connect(VAULT_DECRYPTED) - cursor = db_connection.cursor() - cursor.execute( - ''' CREATE TABLE IF NOT EXISTS accounts (uuid text, application text, - username text, password text, url text) ''' - ) - db_connection.commit() - db_connection.close() - - -def check_table() -> bool: - """Check if the 'accounts' table exists within the vault database.""" - check = False - db_connection = sqlite3.connect(VAULT_DECRYPTED) - cursor = db_connection.cursor() - cursor.execute( - ''' SELECT count(name) FROM sqlite_master WHERE type='table' - AND name='accounts' ''' - ) - if cursor.fetchone()[0] != 1: - user_choice = input( - 'Password vault does not exist. Would you like to create it now? (y/n): ') - if user_choice.lower() == 'y': - create_table() - check = True - else: - sys.exit('Program aborted upon user request.') - else: - check = True - db_connection.commit() - db_connection.close() - return check - - -def add_account(uuid: str, application: str, username: str, password: str, - url: str) -> None: - """Add a new account within the vault database.""" - db_connection = sqlite3.connect(VAULT_DECRYPTED) - cursor = db_connection.cursor() - cursor.execute( - ''' INSERT INTO accounts VALUES (:uuid,:application,:username, - :password,:url) ''', { - 'uuid': uuid, 'application': application, 'username': username, - 'password': password, 'url': url - } - ) - db_connection.commit() - db_connection.close() - - -def delete_account(uuid: str) -> None: - """Delete an account within the vault database by its unique ID.""" - db_connection = sqlite3.connect(VAULT_DECRYPTED) - cursor = db_connection.cursor() - cursor.execute( - ''' DELETE FROM accounts WHERE uuid = :uuid ''', {'uuid': uuid} - ) - db_connection.commit() - db_connection.close() - - -def find_account(uuid: str) -> list: - """Find an account within the vault database by its unique ID.""" - db_connection = sqlite3.connect(VAULT_DECRYPTED) - cursor = db_connection.cursor() - cursor.execute( - ''' SELECT * FROM accounts WHERE uuid = :uuid ''', {'uuid': uuid} - ) - account = cursor.fetchall() - db_connection.close() - return account - - -def find_accounts() -> list: - """Return all accounts stored within the vault database.""" - db_connection = sqlite3.connect(VAULT_DECRYPTED) - cursor = db_connection.cursor() - cursor.execute(''' SELECT * FROM accounts ''') - accounts = cursor.fetchall() - db_connection.close() - return accounts - - -def update_account(field_name: str, new_value: str, uuid: str) -> None: - """Update an account within the vault database by its unique ID.""" - queries = { - 'application': 'UPDATE accounts SET application = :new_value WHERE uuid = :uuid', - 'username': 'UPDATE accounts SET username = :new_value WHERE uuid = :uuid', - 'password': 'UPDATE accounts SET password = :new_value WHERE uuid = :uuid', - 'url': 'UPDATE accounts SET url = :new_value WHERE uuid = :uuid' - } - db_connection = sqlite3.connect(VAULT_DECRYPTED) - cursor = db_connection.cursor() - cursor.execute(queries[field_name], {'new_value': new_value, 'uuid': uuid}) - db_connection.commit() - db_connection.close() - - -def purge_table() -> None: - """Purge the 'accounts' table within the vault database.""" - db_connection = sqlite3.connect(VAULT_DECRYPTED) - cursor = db_connection.cursor() - cursor.execute(''' DROP TABLE accounts ''') - db_connection.commit() - db_connection.close() - - -def purge_database() -> None: - """Purge the entire vault database.""" - os.remove(VAULT_DECRYPTED) diff --git a/src/process.py b/src/process.py deleted file mode 100644 index 0888a78..0000000 --- a/src/process.py +++ /dev/null @@ -1,225 +0,0 @@ -""" -Password Vault Manager - -This script provides various functions for managing password vaults. -It allows users to create, list, edit and delete accounts. - -The `Account` class represents an individual account, with attributes for -the application name, username, password, and URL. The database module is used -to interact with the SQLite database file (`vault.sqlite`) that stores the -accounts data. - -Functions: - generate_characters(n): generates a list of random characters - shuffle_characters(characters): shuffles the characters to create a password - generate_passphrase(n, sep): generates an XKCD-style passphrase with n words and separator - list_accounts(): lists all saved accounts in the database - delete_account(uuid): deletes an account by its UUID - purge_accounts(): purges the entire database (irreversible) - create_account(): creates a new account by prompting user for details - edit_account(uuid, edit_parameter): edits an existing account's details - -Usage: - Run this script in your terminal to access these functions. -""" - -from string import ascii_letters, punctuation, digits -import random -import uuid -from prettytable import PrettyTable -from account import Account -import database - - -def generate_characters(n: int) -> list: - """ - Generates a list of n random characters from the set of ASCII letters, - punctuation and digits. - - Args: - n (int): The number of characters to generate - - Returns: - list: A list of n random characters - """ - characters = [] - password_format = ascii_letters + punctuation + digits - for _ in range(n): - characters.append(random.choice(password_format)) - return characters - - -def shuffle_characters(characters: list) -> str: - """ - Shuffles the characters to create a password. - - Args: - characters (list): The list of characters - - Returns: - str: A string representation of the shuffled characters - """ - random.shuffle(characters) - character_string = ''.join(characters) - return character_string - - -def generate_passphrase(n: int, sep: str) -> str: - """ - Generates an XKCD-style passphrase with n words and separator. - - Args: - n (int): The number of words to include - sep (str): The separator symbol - - Returns: - str: A string representation of the passphrase - """ - phrases = [] - lucky_number = random.choice(range(0, n)) - for _ in range(n): - with open('wordlist.txt', 'r', encoding='utf-8') as file: - line = random.choice(file.readlines()) - line = line.replace('\n', '') - if _ == lucky_number: - phrases.append(line.strip().capitalize() + str(_)) - else: - phrases.append(line.strip().capitalize()) - passphrase = sep.join(phrases) - return passphrase - - -def list_accounts() -> None: - """ - Lists all saved accounts in the database. - - Returns: - None - """ - accounts = database.find_accounts() - t = PrettyTable(['UUID', 'Application', 'Username', 'Password', 'URL']) - for account in accounts: - t.add_row([account[0], account[1], account[2], account[3], account[4]]) - print(t) - - -def delete_account(account_uuid: str) -> None: - """ - Deletes an account by its UUID. - - Args: - account_uuid (str): The UUID of the account to delete - - Returns: - None - """ - account_record = database.find_account(account_uuid) - account = Account(account_record[0][0], - account_record[0][1], - account_record[0][2], - account_record[0][3], - account_record[0][4]) - if account.delete_account(): - print('Account successfully deleted.') - - -def purge_accounts() -> None: - """ - Purges the entire database (irreversible). - - Returns: - None - """ - check = input( - '''Are you absolutely sure you want to delete your password vault? - This action is irreversible. (y/n): ''') - if check.lower() == 'y': - database.purge_table() - database.purge_database() - print('The password vault has been purged. You may now exit or create a new one.') - - -def create_account() -> None: - """ - Creates a new account by prompting user for details. - - Returns: - None - """ - application_string = input('Please enter a name for this account: ') - username_string = input('Please enter your username for this account: ') - url_string = input('(Optional) Please enter a URL for this account: ') - - password_type = input( - '''Do you want a random character password (p), an XKCD-style passphrase -(x), or a custom password (c)? (p|x|c): ''' - ) - if password_type not in ['p', 'x', 'c']: - print('Error: Invalid choice. Please choose p, x, or c.') - return - - if password_type == 'x': - password_length = int( - input('Please enter number of words to include (min. 2): ') - ) - if password_length < 3: - print('Error: Your passphrase length must be at least 3 words.') - return - password_separator = input( - 'Please enter your desired separator symbol (_,-, ~, etc.): ' - ) - password_string = generate_passphrase(password_length, password_separator) - elif password_type == 'p': - password_length = int( - input('Please enter your desired password length (min. 8): ') - ) - if password_length < 8: - print('Error: Your password length must be at least 8 characters.') - return - password_string = generate_password(password_length) # pylint: disable=undefined-variable - else: - password_string = input('Please enter your desired password: ') - - account = Account(str(uuid.uuid4()), application_string, - username_string, password_string, url_string) - account.save_account() - print('Account saved to the vault. Use `--list` to see all saved accounts.') - - - -def edit_account(account_uuid: str, edit_parameter: int) -> None: - """ - Allow users to edit any account information except the UUID. - - Args: - account_uuid (str): Unique identifier of the account. - edit_parameter (int): Parameter indicating which field to edit. - Valid values are 1 for application name, 2 for username, - 3 for password, and 4 for URL. - """ - field_name, new_value = '' - if edit_parameter == 1: - field_name = 'application' - new_value = input('Please enter your desired Application name: ') - elif edit_parameter == 2: - field_name = 'username' - new_value = input('Please enter your desired username: ') - elif edit_parameter == 3: - field_name = 'password' - type_check = input( - 'Do you want a new random password or to enter a custom password? ' - '(random/custom): ').lower() - if type_check == 'random': - password_length = int(input('Please enter your desired password length: ')) - if password_length < 8: - print('Error: Your password length must be at least 8 characters.') - else: - password_characters = generate_characters(password_length) - new_value = shuffle_characters(password_characters) - else: - new_value = input('Please enter your desired password: ') - elif edit_parameter == 4: - field_name = 'url' - new_value = input('Please enter your desired URL: ') - database.update_account(field_name, new_value, account_uuid) - print('Account successfully updated.') diff --git a/src/yoshi_cli.egg-info/PKG-INFO b/src/yoshi_cli.egg-info/PKG-INFO deleted file mode 100644 index e545db5..0000000 --- a/src/yoshi_cli.egg-info/PKG-INFO +++ /dev/null @@ -1,198 +0,0 @@ -Metadata-Version: 2.1 -Name: yoshi-cli -Version: 0.1.0 -Summary: A password manager for the command line. -Author-email: Christian Cleberg -Project-URL: Homepage, https://github.com/ccleberg/yoshi -Project-URL: Issues, https://github.com/ccleberg/yoshi/issues -Classifier: Programming Language :: Python :: 3 -Classifier: License :: OSI Approved :: GNU General Public License v3 (GPLv3) -Classifier: Operating System :: OS Independent -Requires-Python: >=3.8 -Description-Content-Type: text/markdown -License-File: LICENSE - -# Yoshi: A Password Manager - -A simple command-line pass manager, writtin in Python + SQLite3. This tool -allows you to manage accounts and generate random passwords containing ASCII -letters, numbers, and punctuation (min. 8 characters) or XKCD-like passphrases -(min. 3 words). - -Please note that the script is written in Python 3 - you may need to run the -script with the `python3` command instead of `python` if your system uses a -default of Python 2. See the Installation & Usage sections below for more -information. - -# Table of Contents - -- [Installation](#installation) -- [Usage](#usage) - - [Arguments](#arguments) -- [Contributing](#contributing) - -# Installation - -[(Back to top)](#table-of-contents) - -To run the script locally, run the following commands: - -```bash -git clone REPO_URL -``` - -```bash -cd yoshi -``` - -```bash -pip3 install -r requirements.txt -``` - -```bash -python3 main.py --help -``` - -# Usage - -[(Back to top)](#table-of-contents) - -All commands can be passed to the program with the following template: -`python3 main.py ` - -![Yoshi CLI Help](./examples/yoshi-help.png) - -## Arguments - -### Summary - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - -
ArgumentShortcutExplanation
helphPrint the welcome message
newnCreate a new account
listlList all saved accounts
editeEdit a saved account (see below for required flags)
deletedDelete a saved account (see below for required flags)
purgeN/APurge all accounts and delete the vault
encryptN/AEncrypt the vault database (see below for required flags)
decryptN/ADecrypt the vault database (see below for required flags)
- -#### Flags - -Flags for the `edit`, `e` command - both are required: - - - - - - - - - - - - - - - - - - - - - -
ArgumentShortcutExplanation
--uuid-uProvide the account UUID to edit
--field-fProvide the account field to edit
- -Flags for the `delete`, `d` command - this flag is required: - - - - - - - - - - - - - - - - -
ArgumentShortcutExplanation
--uuid-uProvide the account UUID to delete
- -Flags for the `encrypt` or `decrypt` command - you must provide at least one -when encrypting, none are required when decrypting: - - - - - - - - - - - - - - - - - - - - - -
ArgumentShortcutExplanation
--generate-gWhen encrypting, generate a new key
--keyfile-kWhen encrypting or decrypting, provide the path to a saved key file
- -![Yoshi CLI New Account](./examples/yoshi-example.png) - -# Contributing - -[(Back to top)](#table-of-contents) - -Any and all contributions are welcome. Feel free to fork the project, add -features, and submit a pull request. diff --git a/src/yoshi_cli.egg-info/SOURCES.txt b/src/yoshi_cli.egg-info/SOURCES.txt deleted file mode 100644 index c8ca832..0000000 --- a/src/yoshi_cli.egg-info/SOURCES.txt +++ /dev/null @@ -1,11 +0,0 @@ -LICENSE -README.md -pyproject.toml -src/account.py -src/crypto.py -src/database.py -src/process.py -src/yoshi_cli.egg-info/PKG-INFO -src/yoshi_cli.egg-info/SOURCES.txt -src/yoshi_cli.egg-info/dependency_links.txt -src/yoshi_cli.egg-info/top_level.txt \ No newline at end of file diff --git a/src/yoshi_cli.egg-info/dependency_links.txt b/src/yoshi_cli.egg-info/dependency_links.txt deleted file mode 100644 index 8b13789..0000000 --- a/src/yoshi_cli.egg-info/dependency_links.txt +++ /dev/null @@ -1 +0,0 @@ - diff --git a/src/yoshi_cli.egg-info/top_level.txt b/src/yoshi_cli.egg-info/top_level.txt deleted file mode 100644 index e855a8f..0000000 --- a/src/yoshi_cli.egg-info/top_level.txt +++ /dev/null @@ -1,4 +0,0 @@ -account -crypto -database -process diff --git a/yoshi/__init__.py b/yoshi/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/yoshi/__main__.py b/yoshi/__main__.py new file mode 100644 index 0000000..7cadd21 --- /dev/null +++ b/yoshi/__main__.py @@ -0,0 +1,3 @@ +if __name__ == "__main__": + from yoshi.cli import yoshi + yoshi() diff --git a/yoshi/account.py b/yoshi/account.py new file mode 100644 index 0000000..bf97c23 --- /dev/null +++ b/yoshi/account.py @@ -0,0 +1,43 @@ +""" +This script imports necessary modules for database interactions. + +Modules imported: + - database: A custom module providing database functionality. +""" + +import yoshi.database as database + + +class Account: + """Represents a login account.""" + + def __init__(self, uuid: str, application: str, #pylint: disable=R0913,R0917 + username: str, #pylint: disable=R0913,R0917 + password: str, url: str) -> None: #pylint: disable=R0913,R0917 + self.uuid = uuid + self.application = application + self.username = username + self.password = password + self.url = url + + def display_account(self) -> None: + """Print the account details.""" + print('ID:', self.uuid) + print('Application:', self.application) + print('Username:', self.username) + print('Password:', self.password) + print('URL:', self.url) + + def save_account(self) -> None: + """Save the account details to the database.""" + database.add_account( + self.uuid, self.application, self.username, self.password, self.url) + + def delete_account(self) -> bool: + """Delete the account from the database. + + Returns: + bool: True if the deletion was successful. + """ + database.delete_account(self.uuid) + return True diff --git a/yoshi/cli.py b/yoshi/cli.py new file mode 100644 index 0000000..56abae7 --- /dev/null +++ b/yoshi/cli.py @@ -0,0 +1,136 @@ +""" +This script uses argparse to parse command line arguments. + +It imports the required modules and sets up a parser with basic options for demonstration purposes. +""" + +import argparse +import yoshi.crypto as crypto +import yoshi.database as database +import yoshi.process as process + +def yoshi(): + parser = argparse.ArgumentParser( + description='Manage your username and passwords via a convenient CLI vault.' + ) + + # Top-level arguments + group_one = parser.add_mutually_exclusive_group() + group_one.add_argument( + '-n', '--new', + help='Create a new account.', + action='store_true' + ) + group_one.add_argument( + '-l', '--list', + help='List all saved accounts.', + action='store_true' + ) + group_one.add_argument( + '-e', '--edit', + help='Edit a saved account.', + action='store_true' + ) + group_one.add_argument( + '-d', '--delete', + help='Delete a saved account.', + action='store_true' + ) + group_one.add_argument( + '--purge', + help=( + 'Purge all accounts and delete the vault. ' + '(Caution: this will irreversibly destroy your data.)' + ), + action='store_true' + ) + group_one.add_argument( + '--encrypt', + help='Encrypt the vault.', + action='store_true' + ) + group_one.add_argument( + '--decrypt', + help='Decrypt the vault.', + action='store_true' + ) + + # Encryption flags + group_two = parser.add_mutually_exclusive_group() + group_two.add_argument( + '-g', '--generate', + help=( + 'When using the --encrypt option, generate a new encryption key.' + ), + action='store_true' + ) + group_two.add_argument( + '-k', '--keyfile', + help='Path to existing key file.', + action='store', + nargs=1, + type=str + ) + + # Edit flags + group_three = parser.add_argument_group() + group_three.add_argument( + '-u', '--uuid', + help=( + 'When using the --edit or --delete options, provide the account UUID.' + ), + action='store', + nargs=1, + type=str + ) + group_three.add_argument( + '-f', '--field', + help=( + 'When using the --edit option, specify the field to edit (integer index).' + ), + action='store', + nargs=1, + type=int + ) + + args = parser.parse_args() + + if args.decrypt: + if args.keyfile: + key = crypto.load_key(args.keyfile[0]) + else: + key = input('Please enter your decryption key: ') + crypto.decrypt(key) + elif args.encrypt: + if args.generate: + key = crypto.generate_key() + print( + 'WRITE THIS KEY DOWN SOMEWHERE SAFE. YOU WILL NOT BE ABLE TO DECRYPT ' + 'YOUR DATA WITHOUT IT!' + ) + print(key.decode()) + print('\n') + else: + if args.keyfile: + key = crypto.load_key(args.keyfile[0]) + else: + key = input('Please enter your encryption key: ') + crypto.encrypt(key) + elif database.check_table(): + if args.new: + process.create_account() + elif args.list: + process.list_accounts() + elif args.edit: + process.edit_account(args.uuid[0], args.field[0]) + elif args.delete: + process.delete_account(args.uuid[0]) + elif args.purge: + process.purge_accounts() + else: + raise TypeError( + 'Please specify a command or use the --help flag for more information.' + ) + +if __name__ == "__main__": + yoshi() diff --git a/yoshi/crypto.py b/yoshi/crypto.py new file mode 100644 index 0000000..9b0a423 --- /dev/null +++ b/yoshi/crypto.py @@ -0,0 +1,45 @@ +""" +This module imports the Fernet symmetric encryption algorithm from the cryptography library. + +It allows for secure encryption and decryption of data using a secret key. +""" + +from cryptography.fernet import Fernet + +VAULT_FILE = 'vault.sqlite' + + +def generate_key() -> bytes: + """Generates a new encryption key.""" + return Fernet.generate_key() + + +def load_key(key_file: str) -> bytes: + """ + Loads an existing encryption key from the file. + + Args: + key_file (str): Path to the key file. + """ + with open(key_file, 'rb') as key: + return key.read() + + +def encrypt(key: bytes, filename: str = VAULT_FILE) -> None: + """Encrypts the data in the specified file using the provided key.""" + f = Fernet(key) + with open(filename, 'rb') as vault: + data = vault.read() + encrypted_data = f.encrypt(data) + with open(filename, 'wb') as vault: + vault.write(encrypted_data) + + +def decrypt(key: bytes, filename: str = VAULT_FILE) -> None: + """Decrypts the data in the specified file using the provided key.""" + f = Fernet(key) + with open(filename, 'rb') as vault: + encrypted_data = vault.read() + decrypted_data = f.decrypt(encrypted_data) + with open(filename, 'wb') as vault: + vault.write(decrypted_data) diff --git a/yoshi/database.py b/yoshi/database.py new file mode 100644 index 0000000..e1e2e78 --- /dev/null +++ b/yoshi/database.py @@ -0,0 +1,125 @@ +""" +This module provides a basic interface for connecting to and interacting with a SQLite database. +It includes functions for creating connections, executing queries, and retrieving results. +""" + +import sqlite3 +import sys +import os + +VAULT_DECRYPTED = 'vault.sqlite' +VAULT_ENCRYPTED = 'vault.sqlite.aes' + + +def create_table() -> None: + """Create the accounts table within the vault database.""" + db_connection = sqlite3.connect(VAULT_DECRYPTED) + cursor = db_connection.cursor() + cursor.execute( + ''' CREATE TABLE IF NOT EXISTS accounts (uuid text, application text, + username text, password text, url text) ''' + ) + db_connection.commit() + db_connection.close() + + +def check_table() -> bool: + """Check if the 'accounts' table exists within the vault database.""" + check = False + db_connection = sqlite3.connect(VAULT_DECRYPTED) + cursor = db_connection.cursor() + cursor.execute( + ''' SELECT count(name) FROM sqlite_master WHERE type='table' + AND name='accounts' ''' + ) + if cursor.fetchone()[0] != 1: + user_choice = input( + 'Password vault does not exist. Would you like to create it now? (y/n): ') + if user_choice.lower() == 'y': + create_table() + check = True + else: + sys.exit('Program aborted upon user request.') + else: + check = True + db_connection.commit() + db_connection.close() + return check + + +def add_account(uuid: str, application: str, username: str, password: str, + url: str) -> None: + """Add a new account within the vault database.""" + db_connection = sqlite3.connect(VAULT_DECRYPTED) + cursor = db_connection.cursor() + cursor.execute( + ''' INSERT INTO accounts VALUES (:uuid,:application,:username, + :password,:url) ''', { + 'uuid': uuid, 'application': application, 'username': username, + 'password': password, 'url': url + } + ) + db_connection.commit() + db_connection.close() + + +def delete_account(uuid: str) -> None: + """Delete an account within the vault database by its unique ID.""" + db_connection = sqlite3.connect(VAULT_DECRYPTED) + cursor = db_connection.cursor() + cursor.execute( + ''' DELETE FROM accounts WHERE uuid = :uuid ''', {'uuid': uuid} + ) + db_connection.commit() + db_connection.close() + + +def find_account(uuid: str) -> list: + """Find an account within the vault database by its unique ID.""" + db_connection = sqlite3.connect(VAULT_DECRYPTED) + cursor = db_connection.cursor() + cursor.execute( + ''' SELECT * FROM accounts WHERE uuid = :uuid ''', {'uuid': uuid} + ) + account = cursor.fetchall() + db_connection.close() + return account + + +def find_accounts() -> list: + """Return all accounts stored within the vault database.""" + db_connection = sqlite3.connect(VAULT_DECRYPTED) + cursor = db_connection.cursor() + cursor.execute(''' SELECT * FROM accounts ''') + accounts = cursor.fetchall() + db_connection.close() + return accounts + + +def update_account(field_name: str, new_value: str, uuid: str) -> None: + """Update an account within the vault database by its unique ID.""" + queries = { + 'application': 'UPDATE accounts SET application = :new_value WHERE uuid = :uuid', + 'username': 'UPDATE accounts SET username = :new_value WHERE uuid = :uuid', + 'password': 'UPDATE accounts SET password = :new_value WHERE uuid = :uuid', + 'url': 'UPDATE accounts SET url = :new_value WHERE uuid = :uuid' + } + db_connection = sqlite3.connect(VAULT_DECRYPTED) + cursor = db_connection.cursor() + cursor.execute(queries[field_name], {'new_value': new_value, 'uuid': uuid}) + db_connection.commit() + db_connection.close() + + +def purge_table() -> None: + """Purge the 'accounts' table within the vault database.""" + db_connection = sqlite3.connect(VAULT_DECRYPTED) + cursor = db_connection.cursor() + cursor.execute(''' DROP TABLE accounts ''') + db_connection.commit() + db_connection.close() + + +def purge_database() -> None: + """Purge the entire vault database.""" + os.remove(VAULT_DECRYPTED) diff --git a/yoshi/process.py b/yoshi/process.py new file mode 100644 index 0000000..155f9b0 --- /dev/null +++ b/yoshi/process.py @@ -0,0 +1,226 @@ +""" +Password Vault Manager + +This script provides various functions for managing password vaults. +It allows users to create, list, edit and delete accounts. + +The `Account` class represents an individual account, with attributes for +the application name, username, password, and URL. The database module is used +to interact with the SQLite database file (`vault.sqlite`) that stores the +accounts data. + +Functions: + generate_characters(n): generates a list of random characters + shuffle_characters(characters): shuffles the characters to create a password + generate_passphrase(n, sep): generates an XKCD-style passphrase with n words and separator + list_accounts(): lists all saved accounts in the database + delete_account(uuid): deletes an account by its UUID + purge_accounts(): purges the entire database (irreversible) + create_account(): creates a new account by prompting user for details + edit_account(uuid, edit_parameter): edits an existing account's details + +Usage: + Run this script in your terminal to access these functions. +""" + +from string import ascii_letters, punctuation, digits +import random +import uuid +from prettytable import PrettyTable +from yoshi.account import Account +import yoshi.database as database + + +def generate_characters(n: int) -> list: + """ + Generates a list of n random characters from the set of ASCII letters, + punctuation and digits. + + Args: + n (int): The number of characters to generate + + Returns: + list: A list of n random characters + """ + characters = [] + password_format = ascii_letters + punctuation + digits + for _ in range(n): + characters.append(random.choice(password_format)) + return characters + + +def shuffle_characters(characters: list) -> str: + """ + Shuffles the characters to create a password. + + Args: + characters (list): The list of characters + + Returns: + str: A string representation of the shuffled characters + """ + random.shuffle(characters) + character_string = ''.join(characters) + return character_string + + +def generate_passphrase(n: int, sep: str) -> str: + """ + Generates an XKCD-style passphrase with n words and separator. + + Args: + n (int): The number of words to include + sep (str): The separator symbol + + Returns: + str: A string representation of the passphrase + """ + phrases = [] + lucky_number = random.choice(range(0, n)) + for _ in range(n): + with open('wordlist.txt', 'r', encoding='utf-8') as file: + line = random.choice(file.readlines()) + line = line.replace('\n', '') + if _ == lucky_number: + phrases.append(line.strip().capitalize() + str(_)) + else: + phrases.append(line.strip().capitalize()) + passphrase = sep.join(phrases) + return passphrase + + +def list_accounts() -> None: + """ + Lists all saved accounts in the database. + + Returns: + None + """ + accounts = database.find_accounts() + t = PrettyTable(['UUID', 'Application', 'Username', 'Password', 'URL']) + for account in accounts: + t.add_row([account[0], account[1], account[2], account[3], account[4]]) + print(t) + + +def delete_account(account_uuid: str) -> None: + """ + Deletes an account by its UUID. + + Args: + account_uuid (str): The UUID of the account to delete + + Returns: + None + """ + account_record = database.find_account(account_uuid) + account = Account(account_record[0][0], + account_record[0][1], + account_record[0][2], + account_record[0][3], + account_record[0][4]) + if account.delete_account(): + print('Account successfully deleted.') + + +def purge_accounts() -> None: + """ + Purges the entire database (irreversible). + + Returns: + None + """ + check = input( + '''Are you absolutely sure you want to delete your password vault? + This action is irreversible. (y/n): ''') + if check.lower() == 'y': + database.purge_table() + database.purge_database() + print('The password vault has been purged. You may now exit or create a new one.') + + +def create_account() -> None: + """ + Creates a new account by prompting user for details. + + Returns: + None + """ + application_string = input('Please enter a name for this account: ') + username_string = input('Please enter your username for this account: ') + url_string = input('(Optional) Please enter a URL for this account: ') + + password_type = input( + '''Do you want a random character password (p), an XKCD-style passphrase +(x), or a custom password (c)? (p|x|c): ''' + ) + if password_type not in ['p', 'x', 'c']: + print('Error: Invalid choice. Please choose p, x, or c.') + return + + if password_type == 'x': + password_length = int( + input('Please enter number of words to include (min. 2): ') + ) + if password_length < 3: + print('Error: Your passphrase length must be at least 3 words.') + return + password_separator = input( + 'Please enter your desired separator symbol (_,-, ~, etc.): ' + ) + password_string = generate_passphrase(password_length, password_separator) + elif password_type == 'p': + password_length = int( + input('Please enter your desired password length (min. 8): ') + ) + if password_length < 8: + print('Error: Your password length must be at least 8 characters.') + return + password_characters = generate_characters(password_length) + password_string = shuffle_characters(password_characters) + else: + password_string = input('Please enter your desired password: ') + + account = Account(str(uuid.uuid4()), application_string, + username_string, password_string, url_string) + account.save_account() + print('Account saved to the vault. Use `--list` to see all saved accounts.') + + + +def edit_account(account_uuid: str, edit_parameter: int) -> None: + """ + Allow users to edit any account information except the UUID. + + Args: + account_uuid (str): Unique identifier of the account. + edit_parameter (int): Parameter indicating which field to edit. + Valid values are 1 for application name, 2 for username, + 3 for password, and 4 for URL. + """ + field_name, new_value = '' + if edit_parameter == 1: + field_name = 'application' + new_value = input('Please enter your desired Application name: ') + elif edit_parameter == 2: + field_name = 'username' + new_value = input('Please enter your desired username: ') + elif edit_parameter == 3: + field_name = 'password' + type_check = input( + 'Do you want a new random password or to enter a custom password? ' + '(random/custom): ').lower() + if type_check == 'random': + password_length = int(input('Please enter your desired password length: ')) + if password_length < 8: + print('Error: Your password length must be at least 8 characters.') + else: + password_characters = generate_characters(password_length) + new_value = shuffle_characters(password_characters) + else: + new_value = input('Please enter your desired password: ') + elif edit_parameter == 4: + field_name = 'url' + new_value = input('Please enter your desired URL: ') + database.update_account(field_name, new_value, account_uuid) + print('Account successfully updated.') diff --git a/yoshi_cli.egg-info/PKG-INFO b/yoshi_cli.egg-info/PKG-INFO new file mode 100644 index 0000000..085f3f6 --- /dev/null +++ b/yoshi_cli.egg-info/PKG-INFO @@ -0,0 +1,197 @@ +Metadata-Version: 2.1 +Name: yoshi-cli +Version: 0.1.1 +Summary: A password manager for the command line. +Author-email: Christian Cleberg +Project-URL: Homepage, https://github.com/ccleberg/yoshi +Project-URL: Issues, https://github.com/ccleberg/yoshi/issues +Classifier: Programming Language :: Python :: 3 +Classifier: License :: OSI Approved :: GNU General Public License v3 (GPLv3) +Classifier: Operating System :: OS Independent +Requires-Python: >=3.8 +Description-Content-Type: text/markdown +License-File: LICENSE + +# Yoshi: A Password Manager + +A simple command-line pass manager, writtin in Python + SQLite3. This tool +allows you to manage accounts and generate random passwords containing ASCII +letters, numbers, and punctuation (min. 8 characters) or XKCD-like passphrases +(min. 3 words). + +Please note that the script is written in Python 3 - you may need to run the +script with the `python3` command instead of `python` if your system uses a +default of Python 2. See the Installation & Usage sections below for more +information. + +# Table of Contents + +- [Installation](#installation) +- [Usage](#usage) + - [Arguments](#arguments) +- [Contributing](#contributing) + +# Installation + +[(Back to top)](#table-of-contents) + +## PyPi + +```bash +pip install yoshi-cli +``` + +## Manual + +To run the script locally, run the following commands: + +```bash +git clone REPO_URL +cd yoshi +pip3 install -r requirements.txt +python3 src/yoshi/cli.py --help +``` + +# Usage + +[(Back to top)](#table-of-contents) + +All commands can be passed to the program with the following template: +`python3 src/yoshi/cli.py ` + +![Yoshi CLI Help](./examples/yoshi-help.png) + +## Arguments + +### Summary + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
ArgumentShortcutExplanation
helphPrint the welcome message
newnCreate a new account
listlList all saved accounts
editeEdit a saved account (see below for required flags)
deletedDelete a saved account (see below for required flags)
purgeN/APurge all accounts and delete the vault
encryptN/AEncrypt the vault database (see below for required flags)
decryptN/ADecrypt the vault database (see below for required flags)
+ +#### Flags + +Flags for the `edit`, `e` command - both are required: + + + + + + + + + + + + + + + + + + + + + +
ArgumentShortcutExplanation
--uuid-uProvide the account UUID to edit
--field-fProvide the account field to edit
+ +Flags for the `delete`, `d` command - this flag is required: + + + + + + + + + + + + + + + + +
ArgumentShortcutExplanation
--uuid-uProvide the account UUID to delete
+ +Flags for the `encrypt` or `decrypt` command - you must provide at least one +when encrypting, none are required when decrypting: + + + + + + + + + + + + + + + + + + + + + +
ArgumentShortcutExplanation
--generate-gWhen encrypting, generate a new key
--keyfile-kWhen encrypting or decrypting, provide the path to a saved key file
+ +![Yoshi CLI New Account](./examples/yoshi-example.png) + +# Contributing + +[(Back to top)](#table-of-contents) + +Any and all contributions are welcome. Feel free to fork the project, add +features, and submit a pull request. diff --git a/yoshi_cli.egg-info/SOURCES.txt b/yoshi_cli.egg-info/SOURCES.txt new file mode 100644 index 0000000..62143a6 --- /dev/null +++ b/yoshi_cli.egg-info/SOURCES.txt @@ -0,0 +1,15 @@ +LICENSE +README.md +pyproject.toml +yoshi/__init__.py +yoshi/__main__.py +yoshi/account.py +yoshi/cli.py +yoshi/crypto.py +yoshi/database.py +yoshi/process.py +yoshi_cli.egg-info/PKG-INFO +yoshi_cli.egg-info/SOURCES.txt +yoshi_cli.egg-info/dependency_links.txt +yoshi_cli.egg-info/entry_points.txt +yoshi_cli.egg-info/top_level.txt \ No newline at end of file diff --git a/yoshi_cli.egg-info/dependency_links.txt b/yoshi_cli.egg-info/dependency_links.txt new file mode 100644 index 0000000..8b13789 --- /dev/null +++ b/yoshi_cli.egg-info/dependency_links.txt @@ -0,0 +1 @@ + diff --git a/yoshi_cli.egg-info/entry_points.txt b/yoshi_cli.egg-info/entry_points.txt new file mode 100644 index 0000000..cb6e7c1 --- /dev/null +++ b/yoshi_cli.egg-info/entry_points.txt @@ -0,0 +1,2 @@ +[console_scripts] +yoshi = yoshi.cli:yoshi diff --git a/yoshi_cli.egg-info/top_level.txt b/yoshi_cli.egg-info/top_level.txt new file mode 100644 index 0000000..017a863 --- /dev/null +++ b/yoshi_cli.egg-info/top_level.txt @@ -0,0 +1 @@ +yoshi -- cgit v1.2.3-70-g09d2