diff --git a/AGENTS.md b/AGENTS.md index e276706..2671cec 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -38,3 +38,37 @@ This project is written in **Python**. Follow these instructions when working wi - Review code for potential information leaks (e.g., verbose logging) before submitting. Following these practices helps keep the code base consistent and secure. + +## Integrating New Entry Types + +SeedPass supports multiple `kind` values in its JSON entry files. When adding a +new `kind` (for example, SSH keys or BIP‑39 seeds) use the checklist below: + +1. **Menu Updates** – Extend the CLI menus in `main.py` so "Add Entry" offers + choices for the new types and retrieval operations handle them properly. The + current main menu looks like this: + + ``` + Select an option: + 1. Add Entry + 2. Retrieve Entry + 3. Search Entries + 4. Modify an Existing Entry + 5. 2FA Codes + 6. Settings + 7. Exit + ``` + +2. **JSON Schema** – Each entry file must include a `kind` field describing the + entry type. Add new values (`ssh`, `seed`, etc.) as needed and implement + handlers so older kinds continue to work. + +3. **Best Practices** – When introducing a new `kind`, follow the modular + architecture guidelines from `docs/json_entries.md`: + - Use clear, descriptive names. + - Keep handler code for each `kind` separate. + - Validate required fields and gracefully handle missing data. + - Add regression tests to ensure backward compatibility. + +This procedure keeps the UI consistent and ensures new data types integrate +smoothly with existing functionality. diff --git a/README.md b/README.md index 09b2610..bfcaee4 100644 --- a/README.md +++ b/README.md @@ -59,6 +59,30 @@ SeedPass now uses the `portalocker` library for cross-platform file locking. No ## Installation + +### Quick Installer + +Use the automated installer to download SeedPass and its dependencies in one step. + +**Linux and macOS:** +```bash +bash -c "$(curl -sSL https://raw.githubusercontent.com/PR0M3TH3AN/SeedPass/main/scripts/install.sh)" +``` +*Install the beta branch:* +```bash +bash -c "$(curl -sSL https://raw.githubusercontent.com/PR0M3TH3AN/SeedPass/main/scripts/install.sh)" _ -b beta +``` + +**Windows (PowerShell):** +```powershell +Set-ExecutionPolicy Bypass -Scope Process -Force; [System.Net.ServicePointManager]::SecurityProtocol = [System.Net.ServicePointManager]::SecurityProtocol -bor 3072; $scriptContent = (New-Object System.Net.WebClient).DownloadString('https://raw.githubusercontent.com/PR0M3TH3AN/SeedPass/main/scripts/install.ps1'); & ([scriptblock]::create($scriptContent)) +``` +*Install the beta branch:* +```powershell +Set-ExecutionPolicy Bypass -Scope Process -Force; [System.Net.ServicePointManager]::SecurityProtocol = [System.Net.ServicePointManager]::SecurityProtocol -bor 3072; $scriptContent = (New-Object System.Net.WebClient).DownloadString('https://raw.githubusercontent.com/PR0M3TH3AN/SeedPass/main/scripts/install.ps1'); & ([scriptblock]::create($scriptContent)) -Branch beta +``` + +### Manual Setup Follow these steps to set up SeedPass on your local machine. ### 1. Clone the Repository @@ -205,7 +229,8 @@ python src/main.py Enter your choice (1-7): ``` - When choosing **Add Entry**, you can now select **Password** or **2FA (TOTP)**. + When choosing **Add Entry**, you can now select **Password**, **2FA (TOTP)**, + **SSH Key**, **Seed Phrase**, or **PGP Key**. ### Adding a 2FA Entry @@ -287,7 +312,8 @@ Back in the Settings menu you can: * Choose `10` to set an additional backup location. * Select `11` to change the inactivity timeout. * Choose `12` to lock the vault and require re-entry of your password. -* Select `13` to view seed profile stats. +* Select `13` to view seed profile stats. The summary lists counts for + passwords, TOTP codes, SSH keys, seed phrases, and PGP keys. * Choose `14` to toggle Secret Mode and set the clipboard clear delay. * Select `15` to return to the main menu. diff --git a/requirements.lock b/requirements.lock index 8463281..478be5c 100644 --- a/requirements.lock +++ b/requirements.lock @@ -33,6 +33,7 @@ mutmut==2.4.4 nostr-sdk==0.42.1 packaging==25.0 parso==0.8.4 +pgpy==0.6.0 pluggy==1.6.0 pony==0.7.19 portalocker==3.2.0 diff --git a/scripts/install.ps1 b/scripts/install.ps1 new file mode 100644 index 0000000..9c2c4a8 --- /dev/null +++ b/scripts/install.ps1 @@ -0,0 +1,99 @@ +# +# SeedPass Universal Installer for Windows +# +# Supports installing from a specific branch using the -Branch parameter. +# Example: .\install.ps1 -Branch beta + +param( + [string]$Branch = "main" # The git branch to install from +) + +# --- Configuration --- +$RepoUrl = "https://github.com/PR0M3TH3AN/SeedPass.git" +$AppRootDir = Join-Path $env:USERPROFILE ".seedpass" +$InstallDir = Join-Path $AppRootDir "app" +$VenvDir = Join-Path $InstallDir "venv" +$LauncherDir = Join-Path $InstallDir "bin" +$LauncherName = "seedpass.cmd" + +# --- Helper Functions --- +function Write-Info { param([string]$Message) Write-Host "[INFO] $Message" -ForegroundColor Cyan } +function Write-Success { param([string]$Message) Write-Host "[SUCCESS] $Message" -ForegroundColor Green } +function Write-Warning { param([string]$Message) Write-Host "[WARNING] $Message" -ForegroundColor Yellow } +function Write-Error { + param([string]$Message) + Write-Host "[ERROR] $Message" -ForegroundColor Red + Read-Host "Press Enter to exit" + exit 1 +} + +# --- Main Script --- + +# 1. Check for prerequisites +Write-Info "Installing SeedPass from branch: '$Branch'" +Write-Info "Checking for prerequisites..." +if (-not (Get-Command git -ErrorAction SilentlyContinue)) { Write-Error "Git is not installed. Please install it from https://git-scm.com/ and ensure it's in your PATH." } +$pythonExe = Get-Command python -ErrorAction SilentlyContinue +if (-not $pythonExe) { Write-Error "Python 3 is not installed or not in your PATH. Please install it from https://www.python.org/" } + +# 2. Clone or update the repository +if (Test-Path (Join-Path $InstallDir ".git")) { + Write-Info "SeedPass directory found. Fetching updates and switching to '$Branch' branch..." + try { + Set-Location $InstallDir + git fetch origin + git checkout $Branch + git pull origin $Branch --ff-only + } catch { Write-Error "Failed to update repository. Error: $_" } +} else { + Write-Info "Cloning SeedPass '$Branch' branch..." + try { + if (-not(Test-Path $AppRootDir)) { New-Item -ItemType Directory -Path $AppRootDir | Out-Null } + git clone --branch $Branch $RepoUrl $InstallDir + Set-Location $InstallDir + } catch { Write-Error "Failed to clone repository. Error: $_" } +} + +# 3. Set up Python virtual environment +Write-Info "Setting up Python virtual environment..." +if (-not (Test-Path $VenvDir)) { + try { python -m venv $VenvDir } catch { Write-Error "Failed to create virtual environment. Error: $_" } +} + +# 4. Install/Update Python dependencies +Write-Info "Installing/updating Python dependencies..." +try { + & "$VenvDir\Scripts\pip.exe" install --upgrade pip + & "$VenvDir\Scripts\pip.exe" install -r "src\requirements.txt" +} catch { + Write-Warning "Failed to install Python dependencies. If errors mention C++, install Microsoft C++ Build Tools: https://visualstudio.microsoft.com/visual-cpp-build-tools/" + Write-Error "Dependency installation failed. Error: $_" +} + +# 5. Create launcher script +Write-Info "Creating launcher script..." +if (-not (Test-Path $LauncherDir)) { New-Item -ItemType Directory -Path $LauncherDir | Out-Null } +$LauncherPath = Join-Path $LauncherDir $LauncherName +$LauncherContent = @" +@echo off +setlocal +call "%~dp0..\venv\Scripts\activate.bat" +python "%~dp0..\src\main.py" %* +endlocal +"@ +Set-Content -Path $LauncherPath -Value $LauncherContent -Force + +# 6. Add launcher directory to User's PATH if needed +Write-Info "Checking if '$LauncherDir' is in your PATH..." +$UserPath = [System.Environment]::GetEnvironmentVariable("Path", "User") +if (($UserPath -split ';') -notcontains $LauncherDir) { + Write-Info "Adding '$LauncherDir' to your user PATH." + $NewPath = "$LauncherDir;$UserPath".Trim(";") + [System.Environment]::SetEnvironmentVariable("Path", $NewPath, "User") + Write-Warning "PATH has been updated. You MUST open a new terminal for the 'seedpass' command to be available." +} else { + Write-Info "'$LauncherDir' is already in your user PATH." +} + +Write-Success "Installation/update complete!" +Write-Info "To run the application, please open a NEW terminal window and type: seedpass" diff --git a/scripts/install.sh b/scripts/install.sh new file mode 100755 index 0000000..48a72fd --- /dev/null +++ b/scripts/install.sh @@ -0,0 +1,129 @@ +#!/bin/bash +# +# SeedPass Universal Installer for Linux and macOS +# +# Supports installing from a specific branch using the -b or --branch flag. +# Example: ./install.sh -b beta + +set -e + +# --- Configuration --- +REPO_URL="https://github.com/PR0M3TH3AN/SeedPass.git" +APP_ROOT_DIR="$HOME/.seedpass" +INSTALL_DIR="$APP_ROOT_DIR/app" +VENV_DIR="$INSTALL_DIR/venv" +LAUNCHER_DIR="$HOME/.local/bin" +LAUNCHER_PATH="$LAUNCHER_DIR/seedpass" +BRANCH="main" # Default branch + +# --- Helper Functions --- +print_info() { echo -e "\033[1;34m[INFO]\033[0m $1"; } +print_success() { echo -e "\033[1;32m[SUCCESS]\033[0m $1"; } +print_warning() { echo -e "\033[1;33m[WARNING]\033[0m $1"; } +print_error() { echo -e "\033[1;31m[ERROR]\033[0m $1" >&2; exit 1; } +usage() { + echo "Usage: $0 [-b | --branch ] [-h | --help]" + echo " -b, --branch Specify the git branch to install (default: main)" + echo " -h, --help Display this help message" + exit 0 +} + +# --- Main Script --- +main() { + # Parse command-line arguments + while [[ "$#" -gt 0 ]]; do + case "$1" in + -b|--branch) + if [ -n "$2" ]; then + BRANCH="$2" + shift 2 + else + print_error "Error: --branch requires a non-empty option argument." + fi + ;; + -h|--help) + usage + ;; + *) + print_error "Unknown parameter passed: $1"; usage + ;; + esac + done + + # 1. Detect OS + OS_NAME=$(uname -s) + print_info "Installing SeedPass from branch: '$BRANCH'" + print_info "Detected Operating System: $OS_NAME" + + # 2. Check for prerequisites + print_info "Checking for prerequisites (git, python3, pip)..." + if ! command -v git &> /dev/null; then print_error "Git is not installed. Please install it."; fi + if ! command -v python3 &> /dev/null; then print_error "Python 3 is not installed. Please install it."; fi + if ! python3 -m ensurepip --default-pip &> /dev/null && ! command -v pip3 &> /dev/null; then print_error "pip for Python 3 is not available. Please install it."; fi + if ! python3 -c "import venv" &> /dev/null; then + print_warning "Python 'venv' module not found. Attempting to install..." + if [ "$OS_NAME" = "Linux" ]; then + if command -v apt-get &> /dev/null; then sudo apt-get update && sudo apt-get install -y python3-venv; + elif command -v dnf &> /dev/null; then sudo dnf install -y python3-virtualenv; + else print_error "Could not auto-install python3-venv. Please install it for your distribution."; fi + else print_error "Python 'venv' module is missing."; fi + fi + + # 3. Install OS-specific dependencies + print_info "Checking for build dependencies..." + if [ "$OS_NAME" = "Linux" ]; then + if command -v apt-get &> /dev/null; then sudo apt-get update && sudo apt-get install -y build-essential pkg-config xclip; + elif command -v dnf &> /dev/null; then sudo dnf groupinstall -y "Development Tools" && sudo dnf install -y pkg-config xclip; + elif command -v pacman &> /dev/null; then sudo pacman -Syu --noconfirm base-devel pkg-config xclip; + else print_warning "Could not detect package manager. Ensure build tools and pkg-config are installed."; fi + elif [ "$OS_NAME" = "Darwin" ]; then + if ! command -v brew &> /dev/null; then print_error "Homebrew not installed. See https://brew.sh/"; fi + brew install pkg-config + fi + + # 4. Clone or update the repository + if [ -d "$INSTALL_DIR/.git" ]; then + print_info "SeedPass directory found. Fetching updates and switching to '$BRANCH' branch..." + cd "$INSTALL_DIR" + git fetch origin + git checkout "$BRANCH" + git pull origin "$BRANCH" --ff-only + else + print_info "Cloning SeedPass '$BRANCH' branch to '$INSTALL_DIR'..." + mkdir -p "$APP_ROOT_DIR" + git clone --branch "$BRANCH" "$REPO_URL" "$INSTALL_DIR" + cd "$INSTALL_DIR" + fi + + # 5. Set up Python virtual environment + print_info "Setting up Python virtual environment in '$VENV_DIR'..." + if [ ! -d "$VENV_DIR" ]; then python3 -m venv "$VENV_DIR"; fi + # shellcheck source=/dev/null + source "$VENV_DIR/bin/activate" + + # 6. Install/Update Python dependencies + print_info "Installing/updating Python dependencies from src/requirements.txt..." + pip install --upgrade pip + pip install -r src/requirements.txt + deactivate + + # 7. Create launcher script + print_info "Creating launcher script at '$LAUNCHER_PATH'..." + mkdir -p "$LAUNCHER_DIR" + cat > "$LAUNCHER_PATH" << EOF2 +#!/bin/bash +source "$VENV_DIR/bin/activate" +exec python3 "$INSTALL_DIR/src/main.py" "\$@" +EOF2 + chmod +x "$LAUNCHER_PATH" + + # 8. Final instructions + print_success "Installation/update complete!" + print_info "You can now run the application by typing: seedpass" + if [[ ":$PATH:" != *":$LAUNCHER_DIR:"* ]]; then + print_warning "Directory '$LAUNCHER_DIR' is not in your PATH." + print_warning "Please add 'export PATH=\"$HOME/.local/bin:$PATH\"' to your shell's config file (e.g., ~/.bashrc, ~/.zshrc) and restart your terminal." + fi +} + +main "$@" diff --git a/src/main.py b/src/main.py index f8bf76c..2e3a493 100644 --- a/src/main.py +++ b/src/main.py @@ -729,7 +729,10 @@ def display_menu( print("\nAdd Entry:") print("1. Password") print("2. 2FA (TOTP)") - print("3. Back") + print("3. SSH Key") + print("4. Seed Phrase") + print("5. PGP Key") + print("6. Back") sub_choice = input("Select entry type: ").strip() password_manager.update_activity() if sub_choice == "1": @@ -739,6 +742,15 @@ def display_menu( password_manager.handle_add_totp() break elif sub_choice == "3": + password_manager.handle_add_ssh_key() + break + elif sub_choice == "4": + password_manager.handle_add_seed() + break + elif sub_choice == "5": + password_manager.handle_add_pgp() + break + elif sub_choice == "6": break else: print(colored("Invalid choice.", "red")) diff --git a/src/password_manager/entry_management.py b/src/password_manager/entry_management.py index 098ede5..7f1aee7 100644 --- a/src/password_manager/entry_management.py +++ b/src/password_manager/entry_management.py @@ -58,9 +58,13 @@ class EntryManager: if self.index_file.exists(): try: data = self.vault.load_index() - # Ensure legacy entries without a type are treated as passwords + # Normalize legacy fields for entry in data.get("entries", {}).values(): - entry.setdefault("type", EntryType.PASSWORD.value) + if "type" not in entry and "kind" in entry: + entry["type"] = entry["kind"] + if "kind" not in entry: + entry["kind"] = entry.get("type", EntryType.PASSWORD.value) + entry.setdefault("type", entry["kind"]) logger.debug("Index loaded successfully.") return data except Exception as e: @@ -132,6 +136,7 @@ class EntryManager: "url": url if url else "", "blacklisted": blacklisted, "type": EntryType.PASSWORD.value, + "kind": EntryType.PASSWORD.value, "notes": notes, } @@ -158,7 +163,10 @@ class EntryManager: indices = [ int(v.get("index", 0)) for v in entries.values() - if v.get("type") == EntryType.TOTP.value + if ( + v.get("type") == EntryType.TOTP.value + or v.get("kind") == EntryType.TOTP.value + ) ] return (max(indices) + 1) if indices else 0 @@ -183,6 +191,7 @@ class EntryManager: secret = TotpManager.derive_secret(parent_seed, index) entry = { "type": EntryType.TOTP.value, + "kind": EntryType.TOTP.value, "label": label, "index": index, "period": period, @@ -191,6 +200,7 @@ class EntryManager: else: entry = { "type": EntryType.TOTP.value, + "kind": EntryType.TOTP.value, "label": label, "secret": secret, "period": period, @@ -209,34 +219,153 @@ class EntryManager: logger.error(f"Failed to generate otpauth URI: {e}") raise - def add_ssh_key(self, notes: str = "") -> int: - """Placeholder for adding an SSH key entry.""" - index = self.get_next_index() - data = self.vault.load_index() - data.setdefault("entries", {}) - data["entries"][str(index)] = {"type": EntryType.SSH.value, "notes": notes} - self._save_index(data) - self.update_checksum() - self.backup_manager.create_backup() - raise NotImplementedError("SSH key entry support not implemented yet") + def add_ssh_key( + self, parent_seed: str, index: int | None = None, notes: str = "" + ) -> int: + """Add a new SSH key pair entry. + + The provided ``index`` serves both as the vault entry identifier and + derivation index for the key. If not supplied, the next available index + is used. Only metadata is stored – keys are derived on demand. + """ + + if index is None: + index = self.get_next_index() - def add_seed(self, notes: str = "") -> int: - """Placeholder for adding a seed entry.""" - index = self.get_next_index() data = self.vault.load_index() data.setdefault("entries", {}) - data["entries"][str(index)] = {"type": EntryType.SEED.value, "notes": notes} + data["entries"][str(index)] = { + "type": EntryType.SSH.value, + "kind": EntryType.SSH.value, + "index": index, + "notes": notes, + } self._save_index(data) self.update_checksum() self.backup_manager.create_backup() - raise NotImplementedError("Seed entry support not implemented yet") + return index + + def get_ssh_key_pair(self, index: int, parent_seed: str) -> tuple[str, str]: + """Return the PEM formatted SSH key pair for the given entry.""" + + entry = self.retrieve_entry(index) + etype = entry.get("type") if entry else None + kind = entry.get("kind") if entry else None + if not entry or (etype != EntryType.SSH.value and kind != EntryType.SSH.value): + raise ValueError("Entry is not an SSH key entry") + + from password_manager.password_generation import derive_ssh_key_pair + + key_index = int(entry.get("index", index)) + return derive_ssh_key_pair(parent_seed, key_index) + + def add_pgp_key( + self, + parent_seed: str, + index: int | None = None, + key_type: str = "ed25519", + user_id: str = "", + notes: str = "", + ) -> int: + """Add a new PGP key entry.""" + + if index is None: + index = self.get_next_index() + + data = self.vault.load_index() + data.setdefault("entries", {}) + data["entries"][str(index)] = { + "type": EntryType.PGP.value, + "kind": EntryType.PGP.value, + "index": index, + "key_type": key_type, + "user_id": user_id, + "notes": notes, + } + self._save_index(data) + self.update_checksum() + self.backup_manager.create_backup() + return index + + def get_pgp_key(self, index: int, parent_seed: str) -> tuple[str, str]: + """Return the armored PGP private key and fingerprint for the entry.""" + + entry = self.retrieve_entry(index) + etype = entry.get("type") if entry else None + kind = entry.get("kind") if entry else None + if not entry or (etype != EntryType.PGP.value and kind != EntryType.PGP.value): + raise ValueError("Entry is not a PGP key entry") + + from password_manager.password_generation import derive_pgp_key + from local_bip85.bip85 import BIP85 + from bip_utils import Bip39SeedGenerator + + seed_bytes = Bip39SeedGenerator(parent_seed).Generate() + bip85 = BIP85(seed_bytes) + + key_idx = int(entry.get("index", index)) + key_type = entry.get("key_type", "ed25519") + user_id = entry.get("user_id", "") + return derive_pgp_key(bip85, key_idx, key_type, user_id) + + def add_seed( + self, + parent_seed: str, + index: int | None = None, + words_num: int = 24, + notes: str = "", + ) -> int: + """Add a new derived seed phrase entry.""" + + if index is None: + index = self.get_next_index() + + data = self.vault.load_index() + data.setdefault("entries", {}) + data["entries"][str(index)] = { + "type": EntryType.SEED.value, + "kind": EntryType.SEED.value, + "index": index, + "words": words_num, + "notes": notes, + } + self._save_index(data) + self.update_checksum() + self.backup_manager.create_backup() + return index + + def get_seed_phrase(self, index: int, parent_seed: str) -> str: + """Return the mnemonic seed phrase for the given entry.""" + + entry = self.retrieve_entry(index) + etype = entry.get("type") if entry else None + kind = entry.get("kind") if entry else None + if not entry or ( + etype != EntryType.SEED.value and kind != EntryType.SEED.value + ): + raise ValueError("Entry is not a seed entry") + + from password_manager.password_generation import derive_seed_phrase + from local_bip85.bip85 import BIP85 + from bip_utils import Bip39SeedGenerator + + seed_bytes = Bip39SeedGenerator(parent_seed).Generate() + bip85 = BIP85(seed_bytes) + + words = int(entry.get("words", 24)) + seed_index = int(entry.get("index", index)) + return derive_seed_phrase(bip85, seed_index, words) def get_totp_code( self, index: int, parent_seed: str | None = None, timestamp: int | None = None ) -> str: """Return the current TOTP code for the specified entry.""" entry = self.retrieve_entry(index) - if not entry or entry.get("type") != EntryType.TOTP.value: + etype = entry.get("type") if entry else None + kind = entry.get("kind") if entry else None + if not entry or ( + etype != EntryType.TOTP.value and kind != EntryType.TOTP.value + ): raise ValueError("Entry is not a TOTP entry") if "secret" in entry: return TotpManager.current_code_from_secret(entry["secret"], timestamp) @@ -248,7 +377,11 @@ class EntryManager: def get_totp_time_remaining(self, index: int) -> int: """Return seconds remaining in the TOTP period for the given entry.""" entry = self.retrieve_entry(index) - if not entry or entry.get("type") != EntryType.TOTP.value: + etype = entry.get("type") if entry else None + kind = entry.get("kind") if entry else None + if not entry or ( + etype != EntryType.TOTP.value and kind != EntryType.TOTP.value + ): raise ValueError("Entry is not a TOTP entry") period = int(entry.get("period", 30)) @@ -337,7 +470,7 @@ class EntryManager: ) return - entry_type = entry.get("type", EntryType.PASSWORD.value) + entry_type = entry.get("type", entry.get("kind", EntryType.PASSWORD.value)) if entry_type == EntryType.TOTP.value: if label is not None: @@ -414,14 +547,15 @@ class EntryManager: for idx_str, entry in sorted_items: if ( filter_kind is not None - and entry.get("type", EntryType.PASSWORD.value) != filter_kind + and entry.get("type", entry.get("kind", EntryType.PASSWORD.value)) + != filter_kind ): continue filtered_items.append((int(idx_str), entry)) entries: List[Tuple[int, str, Optional[str], Optional[str], bool]] = [] for idx, entry in filtered_items: - etype = entry.get("type", EntryType.PASSWORD.value) + etype = entry.get("type", entry.get("kind", EntryType.PASSWORD.value)) if etype == EntryType.TOTP.value: entries.append((idx, entry.get("label", ""), None, None, False)) else: @@ -437,7 +571,7 @@ class EntryManager: logger.debug(f"Total entries found: {len(entries)}") for idx, entry in filtered_items: - etype = entry.get("type", EntryType.PASSWORD.value) + etype = entry.get("type", entry.get("kind", EntryType.PASSWORD.value)) print(colored(f"Index: {idx}", "cyan")) if etype == EntryType.TOTP.value: print(colored(" Type: TOTP", "cyan")) @@ -484,7 +618,7 @@ class EntryManager: results: List[Tuple[int, str, Optional[str], Optional[str], bool]] = [] for idx, entry in sorted(entries_data.items(), key=lambda x: int(x[0])): - etype = entry.get("type", EntryType.PASSWORD.value) + etype = entry.get("type", entry.get("kind", EntryType.PASSWORD.value)) if etype == EntryType.TOTP.value: label = entry.get("label", "") notes = entry.get("notes", "") diff --git a/src/password_manager/entry_types.py b/src/password_manager/entry_types.py index bfdc5c5..186180b 100644 --- a/src/password_manager/entry_types.py +++ b/src/password_manager/entry_types.py @@ -11,3 +11,4 @@ class EntryType(str, Enum): TOTP = "totp" SSH = "ssh" SEED = "seed" + PGP = "pgp" diff --git a/src/password_manager/manager.py b/src/password_manager/manager.py index c20e0aa..a9a8d90 100644 --- a/src/password_manager/manager.py +++ b/src/password_manager/manager.py @@ -1021,6 +1021,95 @@ class PasswordManager: logging.error(f"Error during TOTP setup: {e}", exc_info=True) print(colored(f"Error: Failed to add TOTP: {e}", "red")) + def handle_add_ssh_key(self) -> None: + """Add an SSH key pair entry and display the derived keys.""" + try: + notes = input("Notes (optional): ").strip() + index = self.entry_manager.add_ssh_key(self.parent_seed, notes=notes) + priv_pem, pub_pem = self.entry_manager.get_ssh_key_pair( + index, self.parent_seed + ) + self.is_dirty = True + self.last_update = time.time() + print(colored(f"\n[+] SSH key entry added with ID {index}.\n", "green")) + print(colored("Public Key:", "cyan")) + print(pub_pem) + print(colored("Private Key:", "cyan")) + print(priv_pem) + try: + self.sync_vault() + except Exception as nostr_error: + logging.error( + f"Failed to post updated index to Nostr: {nostr_error}", + exc_info=True, + ) + except Exception as e: + logging.error(f"Error during SSH key setup: {e}", exc_info=True) + print(colored(f"Error: Failed to add SSH key: {e}", "red")) + + def handle_add_seed(self) -> None: + """Add a derived BIP-39 seed phrase entry.""" + try: + words_input = input("Word count (12 or 24, default 24): ").strip() + notes = input("Notes (optional): ").strip() + if words_input and words_input not in {"12", "24"}: + print(colored("Invalid word count. Choose 12 or 24.", "red")) + return + words = int(words_input) if words_input else 24 + index = self.entry_manager.add_seed( + self.parent_seed, words_num=words, notes=notes + ) + phrase = self.entry_manager.get_seed_phrase(index, self.parent_seed) + self.is_dirty = True + self.last_update = time.time() + print(colored(f"\n[+] Seed entry added with ID {index}.\n", "green")) + print(colored("Seed Phrase:", "cyan")) + print(colored(phrase, "yellow")) + try: + self.sync_vault() + except Exception as nostr_error: + logging.error( + f"Failed to post updated index to Nostr: {nostr_error}", + exc_info=True, + ) + except Exception as e: + logging.error(f"Error during seed phrase setup: {e}", exc_info=True) + print(colored(f"Error: Failed to add seed phrase: {e}", "red")) + + def handle_add_pgp(self) -> None: + """Add a PGP key entry and display the generated key.""" + try: + key_type = ( + input("Key type (ed25519 or rsa, default ed25519): ").strip().lower() + or "ed25519" + ) + user_id = input("User ID (optional): ").strip() + notes = input("Notes (optional): ").strip() + index = self.entry_manager.add_pgp_key( + self.parent_seed, + key_type=key_type, + user_id=user_id, + notes=notes, + ) + priv_key, fingerprint = self.entry_manager.get_pgp_key( + index, self.parent_seed + ) + self.is_dirty = True + self.last_update = time.time() + print(colored(f"\n[+] PGP key entry added with ID {index}.\n", "green")) + print(colored(f"Fingerprint: {fingerprint}", "cyan")) + print(priv_key) + try: + self.sync_vault() + except Exception as nostr_error: # pragma: no cover - best effort + logging.error( + f"Failed to post updated index to Nostr: {nostr_error}", + exc_info=True, + ) + except Exception as e: + logging.error(f"Error during PGP key setup: {e}", exc_info=True) + print(colored(f"Error: Failed to add PGP key: {e}", "red")) + def handle_retrieve_entry(self) -> None: """ Handles retrieving a password from the index by prompting the user for the index number @@ -1028,7 +1117,7 @@ class PasswordManager: """ try: index_input = input( - "Enter the index number of the password to retrieve: " + "Enter the index number of the entry to retrieve: " ).strip() if not index_input.isdigit(): print(colored("Error: Index must be a number.", "red")) @@ -1093,6 +1182,94 @@ class PasswordManager: logging.error(f"Error generating TOTP code: {e}", exc_info=True) print(colored(f"Error: Failed to generate TOTP code: {e}", "red")) return + if entry_type == EntryType.SSH.value: + notes = entry.get("notes", "") + try: + priv_pem, pub_pem = self.entry_manager.get_ssh_key_pair( + index, self.parent_seed + ) + if self.secret_mode_enabled: + copy_to_clipboard(priv_pem, self.clipboard_clear_delay) + print( + colored( + f"[+] SSH private key copied to clipboard. Will clear in {self.clipboard_clear_delay} seconds.", + "green", + ) + ) + print(colored("Public Key:", "cyan")) + print(pub_pem) + else: + print(colored("\n[+] Retrieved SSH Key Pair:\n", "green")) + print(colored("Public Key:", "cyan")) + print(pub_pem) + print(colored("Private Key:", "cyan")) + print(priv_pem) + if notes: + print(colored(f"Notes: {notes}", "cyan")) + except Exception as e: + logging.error(f"Error deriving SSH key pair: {e}", exc_info=True) + print(colored(f"Error: Failed to derive SSH keys: {e}", "red")) + return + if entry_type == EntryType.SEED.value: + notes = entry.get("notes", "") + try: + phrase = self.entry_manager.get_seed_phrase(index, self.parent_seed) + if self.secret_mode_enabled: + copy_to_clipboard(phrase, self.clipboard_clear_delay) + print( + colored( + f"[+] Seed phrase copied to clipboard. Will clear in {self.clipboard_clear_delay} seconds.", + "green", + ) + ) + else: + print(colored("\n[+] Retrieved Seed Phrase:\n", "green")) + print(colored(phrase, "yellow")) + if confirm_action("Show derived entropy as hex? (Y/N): "): + from local_bip85.bip85 import BIP85 + from bip_utils import Bip39SeedGenerator + + words = int(entry.get("words", 24)) + bytes_len = {12: 16, 18: 24, 24: 32}.get(words, 32) + seed_bytes = Bip39SeedGenerator(self.parent_seed).Generate() + bip85 = BIP85(seed_bytes) + entropy = bip85.derive_entropy( + index=int(entry.get("index", index)), + bytes_len=bytes_len, + app_no=39, + words_len=words, + ) + print(colored(f"Entropy: {entropy.hex()}", "cyan")) + if notes: + print(colored(f"Notes: {notes}", "cyan")) + except Exception as e: + logging.error(f"Error deriving seed phrase: {e}", exc_info=True) + print(colored(f"Error: Failed to derive seed phrase: {e}", "red")) + return + if entry_type == EntryType.PGP.value: + notes = entry.get("notes", "") + try: + priv_key, fingerprint = self.entry_manager.get_pgp_key( + index, self.parent_seed + ) + if self.secret_mode_enabled: + copy_to_clipboard(priv_key, self.clipboard_clear_delay) + print( + colored( + f"[+] PGP key copied to clipboard. Will clear in {self.clipboard_clear_delay} seconds.", + "green", + ) + ) + else: + print(colored("\n[+] Retrieved PGP Key:\n", "green")) + print(colored(f"Fingerprint: {fingerprint}", "cyan")) + print(priv_key) + if notes: + print(colored(f"Notes: {notes}", "cyan")) + except Exception as e: + logging.error(f"Error deriving PGP key: {e}", exc_info=True) + print(colored(f"Error: Failed to derive PGP key: {e}", "red")) + return website_name = entry.get("website") length = entry.get("length") @@ -1948,7 +2125,7 @@ class PasswordManager: # Entry counts by type data = self.entry_manager.vault.load_index() entries = data.get("entries", {}) - counts: dict[str, int] = {} + counts: dict[str, int] = {etype.value: 0 for etype in EntryType} for entry in entries.values(): etype = entry.get("type", EntryType.PASSWORD.value) counts[etype] = counts.get(etype, 0) + 1 diff --git a/src/password_manager/password_generation.py b/src/password_manager/password_generation.py index 52970ca..5b80e37 100644 --- a/src/password_manager/password_generation.py +++ b/src/password_manager/password_generation.py @@ -25,8 +25,10 @@ from termcolor import colored from pathlib import Path import shutil from cryptography.hazmat.primitives.kdf.hkdf import HKDF -from cryptography.hazmat.primitives import hashes +from cryptography.hazmat.primitives import hashes, serialization +from cryptography.hazmat.primitives.asymmetric import ed25519 from cryptography.hazmat.backends import default_backend +from bip_utils import Bip39SeedGenerator from local_bip85.bip85 import BIP85 @@ -340,6 +342,123 @@ def derive_ssh_key(bip85: BIP85, idx: int) -> bytes: return bip85.derive_entropy(index=idx, bytes_len=32, app_no=32) +def derive_ssh_key_pair(parent_seed: str, index: int) -> tuple[str, str]: + """Derive an Ed25519 SSH key pair from the seed phrase and index.""" + + seed_bytes = Bip39SeedGenerator(parent_seed).Generate() + bip85 = BIP85(seed_bytes) + entropy = derive_ssh_key(bip85, index) + + private_key = ed25519.Ed25519PrivateKey.from_private_bytes(entropy) + priv_pem = private_key.private_bytes( + serialization.Encoding.PEM, + serialization.PrivateFormat.PKCS8, + serialization.NoEncryption(), + ).decode() + + public_key = private_key.public_key() + pub_pem = public_key.public_bytes( + serialization.Encoding.PEM, + serialization.PublicFormat.SubjectPublicKeyInfo, + ).decode() + + return priv_pem, pub_pem + + def derive_seed_phrase(bip85: BIP85, idx: int, words: int = 24) -> str: """Derive a new BIP39 seed phrase using BIP85.""" return bip85.derive_mnemonic(index=idx, words_num=words) + + +def derive_pgp_key( + bip85: BIP85, idx: int, key_type: str = "ed25519", user_id: str = "" +) -> tuple[str, str]: + """Derive a deterministic PGP private key and return it with its fingerprint.""" + + from pgpy import PGPKey, PGPUID + from pgpy.packet.packets import PrivKeyV4 + from pgpy.packet.fields import ( + EdDSAPriv, + RSAPriv, + ECPoint, + ECPointFormat, + EllipticCurveOID, + MPI, + ) + from pgpy.constants import ( + PubKeyAlgorithm, + KeyFlags, + HashAlgorithm, + SymmetricKeyAlgorithm, + CompressionAlgorithm, + ) + from Crypto.PublicKey import RSA + from Crypto.Util.number import inverse + from cryptography.hazmat.primitives.asymmetric import ed25519 + from cryptography.hazmat.primitives import serialization + import hashlib + import datetime + + entropy = bip85.derive_entropy(index=idx, bytes_len=32, app_no=32) + created = datetime.datetime(2000, 1, 1, tzinfo=datetime.timezone.utc) + + if key_type.lower() == "rsa": + + class DRNG: + def __init__(self, seed: bytes) -> None: + self.seed = seed + + def __call__(self, n: int) -> bytes: # pragma: no cover - deterministic + out = b"" + while len(out) < n: + self.seed = hashlib.sha256(self.seed).digest() + out += self.seed + return out[:n] + + rsa_key = RSA.generate(2048, randfunc=DRNG(entropy)) + keymat = RSAPriv() + keymat.n = MPI(rsa_key.n) + keymat.e = MPI(rsa_key.e) + keymat.d = MPI(rsa_key.d) + keymat.p = MPI(rsa_key.p) + keymat.q = MPI(rsa_key.q) + keymat.u = MPI(inverse(keymat.p, keymat.q)) + keymat._compute_chksum() + + pkt = PrivKeyV4() + pkt.pkalg = PubKeyAlgorithm.RSAEncryptOrSign + pkt.keymaterial = keymat + else: + priv = ed25519.Ed25519PrivateKey.from_private_bytes(entropy) + public = priv.public_key().public_bytes( + serialization.Encoding.Raw, serialization.PublicFormat.Raw + ) + keymat = EdDSAPriv() + keymat.oid = EllipticCurveOID.Ed25519 + keymat.s = MPI(int.from_bytes(entropy, "big")) + keymat.p = ECPoint.from_values( + keymat.oid.key_size, ECPointFormat.Native, public + ) + keymat._compute_chksum() + + pkt = PrivKeyV4() + pkt.pkalg = PubKeyAlgorithm.EdDSA + pkt.keymaterial = keymat + + pkt.created = created + pkt.update_hlen() + key = PGPKey() + key._key = pkt + uid = PGPUID.new(user_id) + key.add_uid( + uid, + usage=[ + KeyFlags.Sign, + KeyFlags.EncryptCommunications, + KeyFlags.EncryptStorage, + ], + hashes=[HashAlgorithm.SHA256], + ciphers=[SymmetricKeyAlgorithm.AES256], + compression=[CompressionAlgorithm.ZLIB], + ) + return str(key), key.fingerprint diff --git a/src/requirements.txt b/src/requirements.txt index 22a2dfd..2a41d78 100644 --- a/src/requirements.txt +++ b/src/requirements.txt @@ -18,6 +18,7 @@ websockets>=15.0.0 tomli hypothesis mutmut==2.4.4 +pgpy==0.6.0 pyotp>=2.8.0 freezegun diff --git a/src/tests/test_backup_restore.py b/src/tests/test_backup_restore.py index 5c223fb..312f14a 100644 --- a/src/tests/test_backup_restore.py +++ b/src/tests/test_backup_restore.py @@ -24,7 +24,13 @@ def test_backup_restore_workflow(monkeypatch): data1 = { "schema_version": 2, "entries": { - "0": {"website": "a", "length": 10, "type": "password", "notes": ""} + "0": { + "website": "a", + "length": 10, + "type": "password", + "kind": "password", + "notes": "", + } }, } vault.save_index(data1) @@ -39,7 +45,13 @@ def test_backup_restore_workflow(monkeypatch): data2 = { "schema_version": 2, "entries": { - "0": {"website": "b", "length": 12, "type": "password", "notes": ""} + "0": { + "website": "b", + "length": 12, + "type": "password", + "kind": "password", + "notes": "", + } }, } vault.save_index(data2) diff --git a/src/tests/test_cli_invalid_input.py b/src/tests/test_cli_invalid_input.py index df64494..c3c1f20 100644 --- a/src/tests/test_cli_invalid_input.py +++ b/src/tests/test_cli_invalid_input.py @@ -77,7 +77,7 @@ def test_out_of_range_menu(monkeypatch, capsys): def test_invalid_add_entry_submenu(monkeypatch, capsys): called = {"add": False, "retrieve": False, "modify": False} pm, _ = _make_pm(called) - inputs = iter(["1", "4", "3", "7"]) + inputs = iter(["1", "7", "6", "7"]) monkeypatch.setattr(main, "timed_input", lambda *_: next(inputs)) monkeypatch.setattr("builtins.input", lambda *_: next(inputs)) with pytest.raises(SystemExit): diff --git a/src/tests/test_entry_add.py b/src/tests/test_entry_add.py index b1f625d..8f6eb52 100644 --- a/src/tests/test_entry_add.py +++ b/src/tests/test_entry_add.py @@ -31,6 +31,7 @@ def test_add_and_retrieve_entry(): "url": "", "blacklisted": False, "type": "password", + "kind": "password", "notes": "", } @@ -61,14 +62,19 @@ def test_round_trip_entry_types(method, expected_type): entry_mgr.add_totp("example", TEST_SEED) index = 0 else: - with pytest.raises(NotImplementedError): - getattr(entry_mgr, method)() - index = 0 + if method == "add_ssh_key": + index = entry_mgr.add_ssh_key(TEST_SEED) + elif method == "add_seed": + index = entry_mgr.add_seed(TEST_SEED) + else: + index = getattr(entry_mgr, method)() entry = entry_mgr.retrieve_entry(index) assert entry["type"] == expected_type + assert entry["kind"] == expected_type data = enc_mgr.load_json_data(entry_mgr.index_file) assert data["entries"][str(index)]["type"] == expected_type + assert data["entries"][str(index)]["kind"] == expected_type def test_legacy_entry_defaults_to_password(): diff --git a/src/tests/test_manager_add_totp.py b/src/tests/test_manager_add_totp.py index 56bec5a..4b159ad 100644 --- a/src/tests/test_manager_add_totp.py +++ b/src/tests/test_manager_add_totp.py @@ -58,6 +58,7 @@ def test_handle_add_totp(monkeypatch, capsys): entry = entry_mgr.retrieve_entry(0) assert entry == { "type": "totp", + "kind": "totp", "label": "Example", "index": 0, "period": 30, diff --git a/src/tests/test_pgp_entry.py b/src/tests/test_pgp_entry.py new file mode 100644 index 0000000..b68db84 --- /dev/null +++ b/src/tests/test_pgp_entry.py @@ -0,0 +1,27 @@ +import sys +from pathlib import Path +from tempfile import TemporaryDirectory + +from helpers import create_vault, TEST_SEED, TEST_PASSWORD + +sys.path.append(str(Path(__file__).resolve().parents[1])) + +from password_manager.entry_management import EntryManager +from password_manager.backup import BackupManager +from password_manager.config_manager import ConfigManager + + +def test_pgp_key_determinism(): + with TemporaryDirectory() as tmpdir: + tmp_path = Path(tmpdir) + vault, enc_mgr = create_vault(tmp_path, TEST_SEED, TEST_PASSWORD) + cfg_mgr = ConfigManager(vault, tmp_path) + backup_mgr = BackupManager(tmp_path, cfg_mgr) + entry_mgr = EntryManager(vault, backup_mgr) + + idx = entry_mgr.add_pgp_key(TEST_SEED, key_type="ed25519", user_id="Test") + key1, fp1 = entry_mgr.get_pgp_key(idx, TEST_SEED) + key2, fp2 = entry_mgr.get_pgp_key(idx, TEST_SEED) + + assert fp1 == fp2 + assert key1 == key2 diff --git a/src/tests/test_seed_entry.py b/src/tests/test_seed_entry.py new file mode 100644 index 0000000..332240b --- /dev/null +++ b/src/tests/test_seed_entry.py @@ -0,0 +1,41 @@ +import sys +from pathlib import Path +from tempfile import TemporaryDirectory + +from helpers import create_vault, TEST_SEED, TEST_PASSWORD + +sys.path.append(str(Path(__file__).resolve().parents[1])) + +from password_manager.entry_management import EntryManager +from password_manager.backup import BackupManager +from password_manager.config_manager import ConfigManager +from password_manager.password_generation import derive_seed_phrase +from local_bip85.bip85 import BIP85 +from bip_utils import Bip39SeedGenerator + + +def test_seed_phrase_determinism(): + with TemporaryDirectory() as tmpdir: + tmp_path = Path(tmpdir) + vault, enc_mgr = create_vault(tmp_path, TEST_SEED, TEST_PASSWORD) + cfg_mgr = ConfigManager(vault, tmp_path) + backup_mgr = BackupManager(tmp_path, cfg_mgr) + entry_mgr = EntryManager(vault, backup_mgr) + + idx_12 = entry_mgr.add_seed(TEST_SEED, words_num=12) + idx_24 = entry_mgr.add_seed(TEST_SEED, words_num=24) + + phrase12_a = entry_mgr.get_seed_phrase(idx_12, TEST_SEED) + phrase12_b = entry_mgr.get_seed_phrase(idx_12, TEST_SEED) + phrase24_a = entry_mgr.get_seed_phrase(idx_24, TEST_SEED) + phrase24_b = entry_mgr.get_seed_phrase(idx_24, TEST_SEED) + + seed_bytes = Bip39SeedGenerator(TEST_SEED).Generate() + bip85 = BIP85(seed_bytes) + expected12 = derive_seed_phrase(bip85, idx_12, 12) + expected24 = derive_seed_phrase(bip85, idx_24, 24) + + assert phrase12_a == phrase12_b == expected12 + assert phrase24_a == phrase24_b == expected24 + assert len(phrase12_a.split()) == 12 + assert len(phrase24_a.split()) == 24 diff --git a/src/tests/test_ssh_entry.py b/src/tests/test_ssh_entry.py new file mode 100644 index 0000000..8873691 --- /dev/null +++ b/src/tests/test_ssh_entry.py @@ -0,0 +1,30 @@ +import sys +from pathlib import Path +from tempfile import TemporaryDirectory + +from helpers import create_vault, TEST_SEED, TEST_PASSWORD + +sys.path.append(str(Path(__file__).resolve().parents[1])) + +from password_manager.entry_management import EntryManager +from password_manager.backup import BackupManager +from password_manager.vault import Vault +from password_manager.config_manager import ConfigManager + + +def test_add_and_retrieve_ssh_key_pair(): + with TemporaryDirectory() as tmpdir: + tmp_path = Path(tmpdir) + vault, enc_mgr = create_vault(tmp_path, TEST_SEED, TEST_PASSWORD) + cfg_mgr = ConfigManager(vault, tmp_path) + backup_mgr = BackupManager(tmp_path, cfg_mgr) + entry_mgr = EntryManager(vault, backup_mgr) + + index = entry_mgr.add_ssh_key(TEST_SEED) + entry = entry_mgr.retrieve_entry(index) + assert entry == {"type": "ssh", "kind": "ssh", "index": index, "notes": ""} + + priv1, pub1 = entry_mgr.get_ssh_key_pair(index, TEST_SEED) + priv2, pub2 = entry_mgr.get_ssh_key_pair(index, TEST_SEED) + assert priv1 == priv2 + assert pub1 == pub2 diff --git a/src/tests/test_totp_entry.py b/src/tests/test_totp_entry.py index 4051505..2b6b301 100644 --- a/src/tests/test_totp_entry.py +++ b/src/tests/test_totp_entry.py @@ -30,6 +30,7 @@ def test_add_totp_and_get_code(): entry = entry_mgr.retrieve_entry(0) assert entry == { "type": "totp", + "kind": "totp", "label": "Example", "index": 0, "period": 30, @@ -66,6 +67,7 @@ def test_add_totp_imported(tmp_path): entry = em.retrieve_entry(0) assert entry == { "type": "totp", + "kind": "totp", "label": "Imported", "secret": secret, "period": 30,