diff --git a/.gitignore b/.gitignore index 5e92e6f..4946766 100644 --- a/.gitignore +++ b/.gitignore @@ -40,4 +40,7 @@ src/seedpass.egg-info/PKG-INFO src/seedpass.egg-info/SOURCES.txt src/seedpass.egg-info/dependency_links.txt src/seedpass.egg-info/entry_points.txt -src/seedpass.egg-info/top_level.txt \ No newline at end of file +src/seedpass.egg-info/top_level.txt + +# Allow vendored dependencies to be committed +!src/vendor/ diff --git a/README.md b/README.md index 0f63233..7b39a4d 100644 --- a/README.md +++ b/README.md @@ -31,6 +31,7 @@ SeedPass now uses the `portalocker` library for cross-platform file locking. No - [Running the Application](#running-the-application) - [Managing Multiple Seeds](#managing-multiple-seeds) - [Additional Entry Types](#additional-entry-types) +- [Building a standalone executable](#building-a-standalone-executable) - [Security Considerations](#security-considerations) - [Contributing](#contributing) - [License](#license) @@ -502,6 +503,41 @@ python -m mutmut results ``` Mutation testing is disabled in the GitHub workflow due to reliability issues and should be run on a desktop environment instead. +## Development Workflow + +1. Install all development dependencies: +```bash +pip install -r src/requirements.txt +``` + +2. When `src/runtime_requirements.txt` changes, rerun: +```bash +scripts/vendor_dependencies.sh +``` +Commit the updated `src/vendor/` directory. The application automatically adds this folder to `sys.path` so the bundled packages are found. + +3. Before committing, format and test the code: +```bash +black . +pytest +``` + + +## Building a standalone executable + +1. Run the vendoring script to bundle runtime dependencies: + +```bash +scripts/vendor_dependencies.sh +``` + +2. Build the binary with PyInstaller: + +```bash +pyinstaller SeedPass.spec +``` + +The standalone executable will appear in the `dist/` directory. This process works on Windows, macOS and Linux but you must build on each platform for a native binary. ## Security Considerations diff --git a/SeedPass.spec b/SeedPass.spec new file mode 100644 index 0000000..8d69efa --- /dev/null +++ b/SeedPass.spec @@ -0,0 +1,38 @@ +# -*- mode: python ; coding: utf-8 -*- + + +a = Analysis( + ['src/main.py'], + pathex=['src', 'src/vendor'], + binaries=[], + datas=[], + hiddenimports=[], + hookspath=[], + hooksconfig={}, + runtime_hooks=[], + excludes=[], + noarchive=False, + optimize=0, +) +pyz = PYZ(a.pure) + +exe = EXE( + pyz, + a.scripts, + a.binaries, + a.datas, + [], + name='SeedPass', + debug=False, + bootloader_ignore_signals=False, + strip=False, + upx=True, + upx_exclude=[], + runtime_tmpdir=None, + console=True, + disable_windowed_traceback=False, + argv_emulation=False, + target_arch=None, + codesign_identity=None, + entitlements_file=None, +) diff --git a/scripts/vendor_dependencies.sh b/scripts/vendor_dependencies.sh new file mode 100755 index 0000000..96191e6 --- /dev/null +++ b/scripts/vendor_dependencies.sh @@ -0,0 +1,12 @@ +#!/usr/bin/env bash +set -euo pipefail + +VENDOR_DIR="src/vendor" + +# Clean vendor directory +rm -rf "$VENDOR_DIR" +mkdir -p "$VENDOR_DIR" + +pip download --no-binary :all: -r src/runtime_requirements.txt -d "$VENDOR_DIR" + +echo "Vendored dependencies installed in $VENDOR_DIR" diff --git a/src/main.py b/src/main.py index 565ade0..6219ca5 100644 --- a/src/main.py +++ b/src/main.py @@ -1,10 +1,15 @@ # main.py -import os from pathlib import Path import sys + +# Add bundled vendor directory to sys.path so bundled dependencies can be imported +vendor_dir = Path(__file__).parent / "vendor" +if vendor_dir.exists(): + sys.path.insert(0, str(vendor_dir)) + +import os import logging import signal -import getpass import time import argparse import asyncio diff --git a/src/password_manager/config_manager.py b/src/password_manager/config_manager.py index 269a10a..eb0e0cf 100644 --- a/src/password_manager/config_manager.py +++ b/src/password_manager/config_manager.py @@ -6,7 +6,7 @@ import logging from pathlib import Path from typing import List, Optional -import getpass +from utils.seed_prompt import masked_input import bcrypt @@ -93,7 +93,7 @@ class ConfigManager: self.save_config(data) if require_pin and data.get("pin_hash"): for _ in range(3): - pin = getpass.getpass("Enter settings PIN: ").strip() + pin = masked_input("Enter settings PIN: ").strip() if bcrypt.checkpw(pin.encode(), data["pin_hash"].encode()): break print("Invalid PIN") diff --git a/src/password_manager/manager.py b/src/password_manager/manager.py index b8ebd09..8434808 100644 --- a/src/password_manager/manager.py +++ b/src/password_manager/manager.py @@ -12,7 +12,6 @@ with the password manager functionalities. import sys import json import logging -import getpass import os import hashlib from typing import Optional, Literal @@ -668,8 +667,8 @@ class PasswordManager: Prompts the user for the master password to decrypt the seed. """ try: - # Prompt for password - password = getpass.getpass(prompt="Enter your login password: ").strip() + # Prompt for password using masked input + password = prompt_existing_password("Enter your login password: ") # Derive encryption key from password iterations = ( diff --git a/src/runtime_requirements.txt b/src/runtime_requirements.txt new file mode 100644 index 0000000..38cf46e --- /dev/null +++ b/src/runtime_requirements.txt @@ -0,0 +1,29 @@ +# Runtime dependencies for vendoring/packaging only +# Generated from requirements.txt with all test-only packages removed +colorama>=0.4.6 +termcolor>=1.1.0 +cryptography>=40.0.2 +bip-utils>=2.5.0 +bech32==1.2.0 +coincurve>=18.0.0 +mnemonic +aiohttp>=3.12.14 +bcrypt +portalocker>=2.8 +nostr-sdk>=0.42.1 +websocket-client==1.7.0 + +websockets>=15.0.0 +tomli +pgpy==0.6.0 +pyotp>=2.8.0 +pyperclip +qrcode>=8.2 +typer>=0.12.3 +fastapi>=0.116.0 +uvicorn>=0.35.0 +httpx>=0.28.1 +requests>=2.32 +python-multipart +orjson +argon2-cffi diff --git a/src/tests/test_password_prompt.py b/src/tests/test_password_prompt.py index e9d04d4..54eda13 100644 --- a/src/tests/test_password_prompt.py +++ b/src/tests/test_password_prompt.py @@ -9,16 +9,14 @@ from utils import password_prompt def test_prompt_new_password(monkeypatch): responses = cycle(["goodpass", "goodpass"]) - monkeypatch.setattr( - password_prompt.getpass, "getpass", lambda prompt: next(responses) - ) + monkeypatch.setattr(password_prompt, "masked_input", lambda prompt: next(responses)) result = password_prompt.prompt_new_password() assert result == "goodpass" def test_prompt_new_password_retry(monkeypatch, caplog): seq = iter(["pass1", "pass2", "passgood", "passgood"]) - monkeypatch.setattr(password_prompt.getpass, "getpass", lambda prompt: next(seq)) + monkeypatch.setattr(password_prompt, "masked_input", lambda prompt: next(seq)) caplog.set_level(logging.WARNING) result = password_prompt.prompt_new_password() assert "User entered a password shorter" in caplog.text @@ -26,7 +24,7 @@ def test_prompt_new_password_retry(monkeypatch, caplog): def test_prompt_existing_password(monkeypatch): - monkeypatch.setattr(password_prompt.getpass, "getpass", lambda prompt: "mypassword") + monkeypatch.setattr(password_prompt, "masked_input", lambda prompt: "mypassword") assert password_prompt.prompt_existing_password() == "mypassword" diff --git a/src/utils/password_prompt.py b/src/utils/password_prompt.py index 065ea0a..498e6b5 100644 --- a/src/utils/password_prompt.py +++ b/src/utils/password_prompt.py @@ -11,11 +11,10 @@ this module enhances code reuse, security, and maintainability across the applic Ensure that all dependencies are installed and properly configured in your environment. """ -import getpass +from utils.seed_prompt import masked_input import logging import sys import unicodedata -import traceback from termcolor import colored from colorama import init as colorama_init @@ -53,8 +52,8 @@ def prompt_new_password() -> str: while attempts < max_retries: try: - password = getpass.getpass(prompt="Enter a new password: ").strip() - confirm_password = getpass.getpass(prompt="Confirm your password: ").strip() + password = masked_input("Enter a new password: ").strip() + confirm_password = masked_input("Confirm your password: ").strip() if not password: print( @@ -128,7 +127,7 @@ def prompt_existing_password( attempts = 0 while attempts < max_retries: try: - password = getpass.getpass(prompt=prompt_message).strip() + password = masked_input(prompt_message).strip() if not password: print(