diff --git a/README.md b/README.md new file mode 100644 index 0000000..cc06ec8 --- /dev/null +++ b/README.md @@ -0,0 +1,135 @@ +# 🐨 KeyKoala + +
+ +![KeyKoala Logo](assets/images/keykoala_logo.png) + +A secure, lightweight, and user-friendly password manager built with Python. + +[![License: MIT](https://img.shields.io/badge/License-MIT-yellow.svg)](https://opensource.org/licenses/MIT) +[![Python 3.7+](https://img.shields.io/badge/python-3.7+-blue.svg)](https://www.python.org/downloads/) +[![Code style: black](https://img.shields.io/badge/code%20style-black-000000.svg)](https://github.com/psf/black) + +
+ +## 🚀 Features + +- 🔐 Master password protection +- 🔒 Strong encryption using Fernet +- 📋 Quick copy passwords to clipboard +- 💾 Secure local storage +- 🖥️ Clean, intuitive GUI +- 🏃 Lightweight and fast +- 📱 Cross-platform support + +## 🛠️ Installation + +1. Clone the repository +```bash +git clone https://github.com/yourusername/keykoala.git +cd keykoala +``` + +2. Create and activate virtual environment +```bash +python -m venv venv +source venv/bin/activate # On Windows: venv\Scripts\activate +``` + +3. Install dependencies +```bash +pip install -r requirements.txt +``` + +4. Install tkinter (if not already installed) + +**Ubuntu/Debian:** +```bash +sudo apt-get install python3-tk +``` + +**Fedora:** +```bash +sudo dnf install python3-tkinter +``` + +**macOS:** +```bash +brew install python-tk +``` + +## 🏃‍♂️ Running KeyKoala + +From the project root directory: +```bash +PYTHONPATH=$PYTHONPATH:$(pwd) python src/main.py +``` + +## 🔒 Security Features + +- Strong encryption using Fernet (symmetric encryption) +- Master password never stored, only used for key derivation +- Passwords only decrypted when copying to clipboard +- All data stored locally on your machine + +## 🤝 Contributing + +Contributions are welcome! Please feel free to submit a Pull Request. For major changes, please open an issue first to discuss what you would like to change. + +1. Fork the repository +2. Create your feature branch (`git checkout -b feature/AmazingFeature`) +3. Commit your changes (`git commit -m 'Add some AmazingFeature'`) +4. Push to the branch (`git push origin feature/AmazingFeature`) +5. Open a Pull Request + +## 📝 Development Tasks + +- [ ] Password generation functionality +- [ ] Password strength checker +- [ ] Import/export functionality +- [ ] Dark mode support +- [ ] Password categories/folders +- [ ] Auto-logout feature +- [ ] Two-factor authentication +- [ ] Browser extension + +## 🐛 Known Issues + +- None reported yet + +## 📦 Dependencies + +- Python 3.7+ +- tkinter +- cryptography +- pyperclip + +## ⚠️ Disclaimer + +KeyKoala is a demonstration project and should not be used as your primary password manager without thorough security auditing. For sensitive information, please use established password managers like Bitwarden, LastPass, or 1Password. + +## 📄 License + +This project is licensed under the MIT License - see the [LICENSE](LICENSE) file for details. + +## 🙏 Acknowledgments + +- Inspired by modern password managers +- Built with Python's cryptography library +- Uses tkinter for the GUI + +## 📧 Contact + +- Project Maintainer: [Blake Ridgway](mailto:blake@blakeridgway.com) +- Project Link: [https://github.com/blakeridgway/keykoala](https://github.com/blakeridgway/keykoala) + +## 🌟 Support + +If you find this project helpful, please give it a star! ⭐ + +--- + +
+Made with ❤️ by Blake Ridgway +
+``` \ No newline at end of file diff --git a/assets/images/keykoala_logo.png b/assets/images/keykoala_logo.png new file mode 100644 index 0000000..9863b3a Binary files /dev/null and b/assets/images/keykoala_logo.png differ diff --git a/requirements.txt b/requirements.txt new file mode 100644 index 0000000..176e3e6 --- /dev/null +++ b/requirements.txt @@ -0,0 +1,2 @@ +cryptography>=3.4.7 +pyperclip>=1.8.2 \ No newline at end of file diff --git a/src/__init__.py b/src/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/src/crypto/__init__.py b/src/crypto/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/src/crypto/encryption.py b/src/crypto/encryption.py new file mode 100644 index 0000000..634e6f4 --- /dev/null +++ b/src/crypto/encryption.py @@ -0,0 +1,28 @@ +# src/crypto/encryption.py +import base64 +from cryptography.fernet import Fernet +from cryptography.hazmat.primitives import hashes +from cryptography.hazmat.primitives.kdf.pbkdf2 import PBKDF2HMAC + +class EncryptionManager: + def __init__(self, master_password, salt): + self.fernet = self._create_fernet(master_password, salt) + + def _create_fernet(self, master_password, salt): + """Create a Fernet instance using the master password and salt.""" + kdf = PBKDF2HMAC( + algorithm=hashes.SHA256(), + length=32, + salt=salt, + iterations=480000, + ) + key = base64.urlsafe_b64encode(kdf.derive(master_password.encode())) + return Fernet(key) + + def encrypt(self, data): + """Encrypt the data.""" + return self.fernet.encrypt(data.encode()).decode() + + def decrypt(self, encrypted_data): + """Decrypt the data.""" + return self.fernet.decrypt(encrypted_data.encode()).decode() diff --git a/src/gui/__init__.py b/src/gui/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/src/gui/app.py b/src/gui/app.py new file mode 100644 index 0000000..4a1f3b9 --- /dev/null +++ b/src/gui/app.py @@ -0,0 +1,44 @@ +import tkinter as tk +from gui.login_screen import LoginScreen +from gui.main_screen import MainScreen +from crypto.encryption import EncryptionManager +from utils.storage import StorageManager + +class PasswordManager: + def __init__(self): + self.root = tk.Tk() + self.root.title("KeyKoala") + self.root.geometry("600x800") + + self.storage_manager = StorageManager() + self.encryption_manager = None + self.current_screen = None + + self.setup_gui() + + def setup_gui(self): + self.show_login_screen() + + def show_login_screen(self): + if self.current_screen: + self.current_screen.destroy() + self.current_screen = LoginScreen(self.root, self.on_login) + + def show_main_screen(self): + if self.current_screen: + self.current_screen.destroy() + self.current_screen = MainScreen( + self.root, + self.encryption_manager, + self.storage_manager + ) + + def on_login(self, master_password): + self.encryption_manager = EncryptionManager( + master_password, + self.storage_manager.get_salt() + ) + self.show_main_screen() + + def run(self): + self.root.mainloop() \ No newline at end of file diff --git a/src/gui/login_screen.py b/src/gui/login_screen.py new file mode 100644 index 0000000..d685c58 --- /dev/null +++ b/src/gui/login_screen.py @@ -0,0 +1,24 @@ +import tkinter as tk +from tkinter import ttk + +class LoginScreen(ttk.Frame): + def __init__(self, parent, login_callback): + super().__init__(parent, padding="20") + self.login_callback = login_callback + self.grid(row=0, column=0, sticky=(tk.W, tk.E, tk.N, tk.S)) + self.setup_gui() + + def setup_gui(self): + ttk.Label(self, text="Master Password:").grid(row=0, column=0, pady=10) + self.password_entry = ttk.Entry(self, show="*") + self.password_entry.grid(row=0, column=1, pady=10) + + ttk.Button( + self, + text="Login", + command=self.handle_login + ).grid(row=1, column=0, columnspan=2, pady=10) + + def handle_login(self): + master_password = self.password_entry.get() + self.login_callback(master_password) diff --git a/src/gui/main_screen.py b/src/gui/main_screen.py new file mode 100644 index 0000000..bf4fa38 --- /dev/null +++ b/src/gui/main_screen.py @@ -0,0 +1,111 @@ +import tkinter as tk +from tkinter import ttk, messagebox +import pyperclip + +class MainScreen(ttk.Frame): + def __init__(self, parent, encryption_manager, storage_manager): + super().__init__(parent, padding="20") + self.encryption_manager = encryption_manager + self.storage_manager = storage_manager + self.grid(row=0, column=0, sticky=(tk.W, tk.E, tk.N, tk.S)) + self.setup_gui() + + def setup_gui(self): + self.create_entry_fields() + self.create_treeview() + self.create_context_menu() + self.load_entries() + + def create_entry_fields(self): + ttk.Label(self, text="Site:").grid(row=0, column=0, pady=5) + self.site_entry = ttk.Entry(self) + self.site_entry.grid(row=0, column=1, pady=5) + + ttk.Label(self, text="Username:").grid(row=1, column=0, pady=5) + self.username_entry = ttk.Entry(self) + self.username_entry.grid(row=1, column=1, pady=5) + + ttk.Label(self, text="Password:").grid(row=2, column=0, pady=5) + self.password_entry = ttk.Entry(self, show="*") + self.password_entry.grid(row=2, column=1, pady=5) + + ttk.Button( + self, + text="Add Entry", + command=self.add_entry + ).grid(row=3, column=0, columnspan=2, pady=10) + + def create_treeview(self): + columns = ("Site", "Username") + self.tree = ttk.Treeview(self, columns=columns, show="headings") + + for col in columns: + self.tree.heading(col, text=col) + self.tree.column(col, width=100) + + self.tree.grid(row=4, column=0, columnspan=2, pady=10) + + def create_context_menu(self): + self.popup_menu = tk.Menu(self, tearoff=0) + self.popup_menu.add_command( + label="Copy Password", + command=self.copy_password + ) + self.popup_menu.add_command( + label="Delete Entry", + command=self.delete_entry + ) + + self.tree.bind("", self.show_popup_menu) + + def show_popup_menu(self, event): + item = self.tree.identify_row(event.y) + if item: + self.tree.selection_set(item) + self.popup_menu.post(event.x_root, event.y_root) + + def add_entry(self): + site = self.site_entry.get() + username = self.username_entry.get() + password = self.password_entry.get() + + if not all([site, username, password]): + messagebox.showerror("Error", "All fields are required!") + return + + encrypted_password = self.encryption_manager.encrypt(password) + self.storage_manager.add_entry(site, username, encrypted_password) + self.load_entries() + self.clear_entries() + + def clear_entries(self): + self.site_entry.delete(0, tk.END) + self.username_entry.delete(0, tk.END) + self.password_entry.delete(0, tk.END) + + def load_entries(self): + self.tree.delete(*self.tree.get_children()) + entries = self.storage_manager.get_all_entries() + + for site, data in entries.items(): + self.tree.insert("", tk.END, values=(site, data["username"])) + + def copy_password(self): + selected = self.tree.selection() + if not selected: + return + + site = self.tree.item(selected[0])["values"][0] + encrypted_password = self.storage_manager.get_password(site) + decrypted_password = self.encryption_manager.decrypt(encrypted_password) + pyperclip.copy(decrypted_password) + messagebox.showinfo("Success", "Password copied to clipboard!") + + def delete_entry(self): + selected = self.tree.selection() + if not selected: + return + + site = self.tree.item(selected[0])["values"][0] + self.storage_manager.delete_entry(site) + self.load_entries() diff --git a/src/main.py b/src/main.py new file mode 100644 index 0000000..6a7359d --- /dev/null +++ b/src/main.py @@ -0,0 +1,8 @@ +from gui.app import PasswordManager + +def main(): + app = PasswordManager() + app.run() + +if __name__ == "__main__": + main() \ No newline at end of file diff --git a/src/utils/__init__.py b/src/utils/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/src/utils/storage.py b/src/utils/storage.py new file mode 100644 index 0000000..0790ad3 --- /dev/null +++ b/src/utils/storage.py @@ -0,0 +1,68 @@ +import json +import os +import base64 +import secrets + +class StorageManager: + def __init__(self, data_file="passwords.enc"): + self.data_file = data_file + self.config_file = "config.json" + self.passwords = {} + self.salt = self._get_or_create_salt() + self._load_data() + + def _get_or_create_salt(self): + """Generate or retrieve the salt from config file.""" + if os.path.exists(self.config_file): + try: + with open(self.config_file, "r") as f: + config = json.load(f) + return base64.b64decode(config["salt"]) + except Exception: + return self._create_new_salt() + return self._create_new_salt() + + def _create_new_salt(self): + """Generate a new salt and save it to config.""" + salt = secrets.token_bytes(32) # 32 bytes = 256 bits + config = {"salt": base64.b64encode(salt).decode()} + + with open(self.config_file, "w") as f: + json.dump(config, f) + + return salt + + def get_salt(self): + """Return the current salt.""" + return self.salt + + def _load_data(self): + if os.path.exists(self.data_file): + try: + with open(self.data_file, "r") as f: + return json.load(f) + except Exception: + return {} + return {} + + def _save_data(self): + with open(self.data_file, "w") as f: + json.dump(self.passwords, f) + + def add_entry(self, site, username, encrypted_password): + self.passwords[site] = { + "username": username, + "password": encrypted_password + } + self._save_data() + + def get_all_entries(self): + return self.passwords + + def get_password(self, site): + return self.passwords[site]["password"] + + def delete_entry(self, site): + if site in self.passwords: + del self.passwords[site] + self._save_data()