32 Commits

Author SHA1 Message Date
thePR0M3TH3AN
6928b4ddbf Merge pull request #826 from PR0M3TH3AN/codex/add-tests-for-seed-word-flow
test: add word-by-word seed flow tests
2025-08-18 22:03:41 -04:00
thePR0M3TH3AN
73183d53a5 test: cover invalid word and fingerprint flows 2025-08-18 21:55:39 -04:00
thePR0M3TH3AN
c9ad16f150 Merge pull request #825 from PR0M3TH3AN/codex/extend-test-coverage-for-key/value-and-managed-accounts
test: add key-value and managed account entry tests
2025-08-18 21:18:20 -04:00
thePR0M3TH3AN
bd86bdbb3a test: add key-value and managed account entry tests 2025-08-18 21:12:32 -04:00
thePR0M3TH3AN
8d5374ef5b Merge pull request #824 from PR0M3TH3AN/codex/add-all_entry_types-constant-and-update-filters
Support listing all entry types
2025-08-18 19:22:05 -04:00
thePR0M3TH3AN
468608a369 Support listing all entry types 2025-08-18 19:12:55 -04:00
thePR0M3TH3AN
56e652089a Merge pull request #823 from PR0M3TH3AN/codex/update-documentation-for-installation-requirements
docs: note installer dependency checks
2025-08-18 18:19:33 -04:00
thePR0M3TH3AN
c353c04472 docs: note installer dependency checks 2025-08-18 18:18:07 -04:00
thePR0M3TH3AN
2559920a14 Merge pull request #822 from PR0M3TH3AN/codex/update-readme-and-examples-for-seedpass-commands
docs: update vault import/export commands
2025-08-18 18:11:18 -04:00
thePR0M3TH3AN
57935bdfc1 docs: update vault import/export commands 2025-08-18 18:09:43 -04:00
thePR0M3TH3AN
55fdee522c Merge pull request #821 from PR0M3TH3AN/codex/update-install.sh-argument-parsing
Default GUI install with opt-out flag
2025-08-18 18:00:14 -04:00
thePR0M3TH3AN
af4eb72385 Default to GUI install with opt-out flag 2025-08-18 17:50:18 -04:00
thePR0M3TH3AN
90c304ff6e Merge pull request #820 from PR0M3TH3AN/codex/add-post-install-python-check
Run CLI import check after pip install
2025-08-18 17:32:25 -04:00
thePR0M3TH3AN
7b1ef2abe2 Relax BIP85 cache benchmark 2025-08-18 17:22:34 -04:00
thePR0M3TH3AN
5194adf145 Check CLI import after installation 2025-08-18 17:02:23 -04:00
thePR0M3TH3AN
8f74ac27f4 Merge pull request #819 from PR0M3TH3AN/codex/add-dependency-installation-for-multiple-os
feat: expand install dependencies
2025-08-18 16:02:53 -04:00
thePR0M3TH3AN
1232630dba feat: expand install dependencies 2025-08-18 15:56:29 -04:00
thePR0M3TH3AN
62983df69c Merge pull request #817 from PR0M3TH3AN/codex/update-install.sh-for-stricter-error-handling
chore: harden installer script
2025-08-16 11:44:48 -04:00
thePR0M3TH3AN
b4238791aa chore: harden installer script 2025-08-16 11:30:48 -04:00
thePR0M3TH3AN
d1fccbc4f2 Merge pull request #816 from PR0M3TH3AN/codex/rename-seed_bytes-to-seed_or_xprv
Refine BIP85 initialization handling
2025-08-12 11:13:10 -04:00
thePR0M3TH3AN
50532597b8 Test BIP85 init with seed bytes and xprv 2025-08-12 11:01:17 -04:00
thePR0M3TH3AN
bb733bb194 Merge pull request #815 from PR0M3TH3AN/codex/remove-src/nostr/utils.py-and-its-imports
chore: remove unused nostr utils module
2025-08-12 10:34:30 -04:00
thePR0M3TH3AN
785acf938c chore: remove unused nostr utils 2025-08-12 10:26:51 -04:00
thePR0M3TH3AN
4973095a5c Merge pull request #814 from PR0M3TH3AN/codex/remove-logging_config.py-and-update-references
Remove obsolete Nostr logging configuration module
2025-08-12 10:21:18 -04:00
thePR0M3TH3AN
69f1619816 Remove obsolete logging configuration module 2025-08-12 10:03:54 -04:00
thePR0M3TH3AN
e1b821bc55 Merge pull request #813 from PR0M3TH3AN/codex/rename-parameters-and-update-documentation
refactor: rename entropy length parameter
2025-08-12 09:57:11 -04:00
thePR0M3TH3AN
a21efa91db refactor: rename entropy length parameter 2025-08-12 09:41:37 -04:00
thePR0M3TH3AN
5109f96ce7 Merge pull request #812 from PR0M3TH3AN/codex/implement-vault-locking-mechanism
Track vault lock state with explicit lock flag
2025-08-12 09:22:22 -04:00
thePR0M3TH3AN
19577163cf Add vault locked flag and enforce access checks 2025-08-12 08:48:19 -04:00
thePR0M3TH3AN
b0e4ab9bc6 Merge pull request #811 from PR0M3TH3AN/codex/refactor-passwordmanager-into-services
Refactor PasswordManager into composable services
2025-08-11 21:20:55 -04:00
thePR0M3TH3AN
3ff3e4e1d6 Use manager timed_input in MenuHandler 2025-08-11 21:13:44 -04:00
thePR0M3TH3AN
08c4453326 Add service classes and tests 2025-08-11 20:36:53 -04:00
45 changed files with 1252 additions and 693 deletions

View File

@@ -146,6 +146,10 @@ The Windows installer will attempt to install Git automatically if it is not alr
**Note:** If this fallback fails, install Python 3.12 manually or install the [Microsoft Visual C++ Build Tools](https://visualstudio.microsoft.com/visual-cpp-build-tools/) and rerun the installer. **Note:** If this fallback fails, install Python 3.12 manually or install the [Microsoft Visual C++ Build Tools](https://visualstudio.microsoft.com/visual-cpp-build-tools/) and rerun the installer.
#### Installer Dependency Checks
The installer verifies that core build tooling—C/C++ build tools, Rust, CMake, and the imaging/GTK libraries—are available before completing. Pass `--no-gui` to skip installing GUI packages. On Linux, ensure `xclip` or `wl-clipboard` is installed for clipboard support.
#### Windows Nostr Sync Troubleshooting #### Windows Nostr Sync Troubleshooting
When backing up or restoring from Nostr on Windows, a few issues are common: When backing up or restoring from Nostr on Windows, a few issues are common:
@@ -274,10 +278,10 @@ You can then launch SeedPass and create a backup:
seedpass seedpass
# Export your index # Export your index
seedpass export --file "~/seedpass_backup.json" seedpass vault export --file "~/seedpass_backup.json"
# Later you can restore it # Later you can restore it
seedpass import --file "~/seedpass_backup.json" seedpass vault import --file "~/seedpass_backup.json"
# Quickly find or retrieve entries # Quickly find or retrieve entries
seedpass search "github" seedpass search "github"

View File

@@ -120,6 +120,11 @@ isn't on your PATH. If these tools are unavailable you'll see a link to download
the installer now attempts to download Python 3.12 automatically so you don't have to compile packages from source. the installer now attempts to download Python 3.12 automatically so you don't have to compile packages from source.
**Note:** If this fallback fails, install Python 3.12 manually or install the [Microsoft Visual C++ Build Tools](https://visualstudio.microsoft.com/visual-cpp-build-tools/) and rerun the installer. **Note:** If this fallback fails, install Python 3.12 manually or install the [Microsoft Visual C++ Build Tools](https://visualstudio.microsoft.com/visual-cpp-build-tools/) and rerun the installer.
#### Installer Dependency Checks
The installer verifies that core build tooling—C/C++ build tools, Rust, CMake, and the imaging/GTK libraries—are available before completing. Pass `--no-gui` to skip installing GUI packages. On Linux, ensure `xclip` or `wl-clipboard` is installed for clipboard support.
### Uninstall ### Uninstall
Run the matching uninstaller if you need to remove a previous installation or clean up an old `seedpass` command: Run the matching uninstaller if you need to remove a previous installation or clean up an old `seedpass` command:

View File

@@ -5,7 +5,9 @@
# Supports installing from a specific branch using the -b or --branch flag. # Supports installing from a specific branch using the -b or --branch flag.
# Example: ./install.sh -b beta # Example: ./install.sh -b beta
set -e set -euo pipefail
IFS=$'\n\t'
trap 'echo "[ERROR] Line $LINENO failed"; exit 1' ERR
# --- Configuration --- # --- Configuration ---
REPO_URL="https://github.com/PR0M3TH3AN/SeedPass.git" REPO_URL="https://github.com/PR0M3TH3AN/SeedPass.git"
@@ -15,13 +17,13 @@ VENV_DIR="$INSTALL_DIR/venv"
LAUNCHER_DIR="$HOME/.local/bin" LAUNCHER_DIR="$HOME/.local/bin"
LAUNCHER_PATH="$LAUNCHER_DIR/seedpass" LAUNCHER_PATH="$LAUNCHER_DIR/seedpass"
BRANCH="main" # Default branch BRANCH="main" # Default branch
INSTALL_GUI=false INSTALL_GUI=true
# --- Helper Functions --- # --- Helper Functions ---
print_info() { echo -e "\033[1;34m[INFO]\033[0m $1"; } print_info() { echo -e "\033[1;34m[INFO]\033[0m" "$1"; }
print_success() { echo -e "\033[1;32m[SUCCESS]\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_warning() { echo -e "\033[1;33m[WARNING]\033[0m" "$1"; }
print_error() { echo -e "\033[1;31m[ERROR]\033[0m $1" >&2; exit 1; } print_error() { echo -e "\033[1;31m[ERROR]\033[0m" "$1" >&2; exit 1; }
# Install build dependencies for Gtk/GObject if available via the system package manager # Install build dependencies for Gtk/GObject if available via the system package manager
install_dependencies() { install_dependencies() {
@@ -30,29 +32,36 @@ install_dependencies() {
sudo apt-get update && sudo apt-get install -y \\ sudo apt-get update && sudo apt-get install -y \\
build-essential pkg-config libcairo2 libcairo2-dev \\ build-essential pkg-config libcairo2 libcairo2-dev \\
libgirepository1.0-dev gobject-introspection \\ libgirepository1.0-dev gobject-introspection \\
gir1.2-gtk-3.0 python3-dev libffi-dev libssl-dev gir1.2-gtk-3.0 libgtk-3-dev python3-dev libffi-dev libssl-dev \\
cmake rustc cargo zlib1g-dev libjpeg-dev libpng-dev \\
libfreetype6-dev xclip wl-clipboard
elif command -v yum &>/dev/null; then elif command -v yum &>/dev/null; then
sudo yum install -y @'Development Tools' cairo cairo-devel \\ sudo yum install -y @'Development Tools' cairo cairo-devel \\
gobject-introspection-devel gtk3-devel python3-devel \\ gobject-introspection-devel gtk3-devel python3-devel \\
libffi-devel openssl-devel libffi-devel openssl-devel cmake rust cargo zlib-devel \\
libjpeg-turbo-devel libpng-devel freetype-devel xclip \\
wl-clipboard
elif command -v dnf &>/dev/null; then elif command -v dnf &>/dev/null; then
sudo dnf groupinstall -y "Development Tools" && sudo dnf install -y \\ sudo dnf groupinstall -y "Development Tools" && sudo dnf install -y \\
cairo cairo-devel gobject-introspection-devel gtk3-devel \\ cairo cairo-devel gobject-introspection-devel gtk3-devel \\
python3-devel libffi-devel openssl-devel python3-devel libffi-devel openssl-devel cmake rust cargo \\
zlib-devel libjpeg-turbo-devel libpng-devel freetype-devel \\
xclip wl-clipboard
elif command -v pacman &>/dev/null; then elif command -v pacman &>/dev/null; then
sudo pacman -Syu --noconfirm base-devel pkgconf cairo \\ sudo pacman -Syu --noconfirm base-devel pkgconf cmake rustup \\
gobject-introspection gtk3 python gtk3 gobject-introspection cairo libjpeg-turbo zlib \\
libpng freetype xclip wl-clipboard && rustup default stable
elif command -v brew &>/dev/null; then elif command -v brew &>/dev/null; then
brew install pkg-config cairo gobject-introspection gtk+3 brew install pkg-config cairo gobject-introspection gtk+3 cmake rustup-init && \\
rustup-init -y
else else
print_warning "Unsupported package manager. Please install Gtk/GObject dependencies manually." print_warning "Unsupported package manager. Please install Gtk/GObject dependencies manually."
fi fi
print_warning "Install 'xclip' manually to enable clipboard features in secret mode."
} }
usage() { usage() {
echo "Usage: $0 [-b | --branch <branch_name>] [--with-gui] [-h | --help]" echo "Usage: $0 [-b | --branch <branch_name>] [--no-gui] [-h | --help]"
echo " -b, --branch Specify the git branch to install (default: main)" echo " -b, --branch Specify the git branch to install (default: main)"
echo " --with-gui Include graphical interface dependencies" echo " --no-gui Skip graphical interface dependencies (default: include GUI)"
echo " -h, --help Display this help message" echo " -h, --help Display this help message"
exit 0 exit 0
} }
@@ -73,8 +82,8 @@ main() {
-h|--help) -h|--help)
usage usage
;; ;;
--with-gui) --no-gui)
INSTALL_GUI=true INSTALL_GUI=false
shift shift
;; ;;
*) *)
@@ -179,6 +188,11 @@ main() {
else else
pip install -e . pip install -e .
fi fi
if ! "$VENV_DIR/bin/python" -c "import seedpass.cli; print('ok')"; then
print_error "SeedPass CLI import check failed."
fi
deactivate deactivate
# 7. Create launcher script # 7. Create launcher script

View File

@@ -6,10 +6,10 @@ logger = logging.getLogger(__name__)
try: try:
from .bip85 import BIP85 from .bip85 import BIP85
except Exception as exc:
logger.error("Failed to import BIP85 module: %s", exc, exc_info=True)
raise ImportError(
"BIP85 dependencies are missing. Install 'bip_utils', 'cryptography', and 'colorama'."
) from exc
logger.info("BIP85 module imported successfully.") __all__ = ["BIP85"]
except Exception as e:
logger.error(f"Failed to import BIP85 module: {e}", exc_info=True)
BIP85 = None
__all__ = ["BIP85"] if BIP85 is not None else []

View File

@@ -18,6 +18,8 @@ import hashlib
import hmac import hmac
import logging import logging
import os import os
from typing import Union
from colorama import Fore from colorama import Fore
from bip_utils import Bip32Slip10Secp256k1, Bip39MnemonicGenerator, Bip39Languages from bip_utils import Bip32Slip10Secp256k1, Bip39MnemonicGenerator, Bip39Languages
@@ -37,13 +39,19 @@ class Bip85Error(Exception):
class BIP85: class BIP85:
def __init__(self, seed_bytes: bytes | str): def __init__(self, seed_or_xprv: Union[bytes, str]):
"""Initialize from BIP39 seed bytes or BIP32 xprv string.""" """Initialize from seed bytes or an ``xprv`` string.
Parameters:
seed_or_xprv (Union[bytes, str]): Either raw BIP39 seed bytes
or a BIP32 extended private key (``xprv``) string.
"""
try: try:
if isinstance(seed_bytes, (bytes, bytearray)): if isinstance(seed_or_xprv, (bytes, bytearray)):
self.bip32_ctx = Bip32Slip10Secp256k1.FromSeed(seed_bytes) self.bip32_ctx = Bip32Slip10Secp256k1.FromSeed(seed_or_xprv)
else: else:
self.bip32_ctx = Bip32Slip10Secp256k1.FromExtendedKey(seed_bytes) self.bip32_ctx = Bip32Slip10Secp256k1.FromExtendedKey(seed_or_xprv)
logging.debug("BIP32 context initialized successfully.") logging.debug("BIP32 context initialized successfully.")
except Exception as e: except Exception as e:
logging.error(f"Error initializing BIP32 context: {e}", exc_info=True) logging.error(f"Error initializing BIP32 context: {e}", exc_info=True)
@@ -51,26 +59,34 @@ class BIP85:
raise Bip85Error(f"Error initializing BIP32 context: {e}") raise Bip85Error(f"Error initializing BIP32 context: {e}")
def derive_entropy( def derive_entropy(
self, index: int, bytes_len: int, app_no: int = 39, words_len: int | None = None self,
index: int,
entropy_bytes: int,
app_no: int = 39,
word_count: int | None = None,
) -> bytes: ) -> bytes:
""" """Derive entropy using the BIP-85 HMAC-SHA512 method.
Derives entropy using BIP-85 HMAC-SHA512 method.
Parameters: Parameters:
index (int): Index for the child entropy. index (int): Index for the child entropy.
bytes_len (int): Number of bytes to derive for the entropy. entropy_bytes (int): Number of bytes of entropy to derive.
app_no (int): Application number (default 39 for BIP39) app_no (int): Application number (default 39 for BIP39).
word_count (int | None): Number of words used in the derivation path
for BIP39. If ``None`` and ``app_no`` is ``39``, ``word_count``
defaults to ``entropy_bytes``. The final segment of the
derivation path becomes ``m/83696968'/39'/0'/word_count'/index'``.
Returns: Returns:
bytes: Derived entropy. bytes: Derived entropy of length ``entropy_bytes``.
Raises: Raises:
SystemExit: If derivation fails or entropy length is invalid. SystemExit: If derivation fails or the derived entropy length is
invalid.
""" """
if app_no == 39: if app_no == 39:
if words_len is None: if word_count is None:
words_len = bytes_len word_count = entropy_bytes
path = f"m/83696968'/{app_no}'/0'/{words_len}'/{index}'" path = f"m/83696968'/{app_no}'/0'/{word_count}'/{index}'"
elif app_no == 32: elif app_no == 32:
path = f"m/83696968'/{app_no}'/{index}'" path = f"m/83696968'/{app_no}'/{index}'"
else: else:
@@ -86,17 +102,17 @@ class BIP85:
hmac_result = hmac.new(hmac_key, k, hashlib.sha512).digest() hmac_result = hmac.new(hmac_key, k, hashlib.sha512).digest()
logging.debug(f"HMAC-SHA512 result: {hmac_result.hex()}") logging.debug(f"HMAC-SHA512 result: {hmac_result.hex()}")
entropy = hmac_result[:bytes_len] entropy = hmac_result[:entropy_bytes]
if len(entropy) != bytes_len: if len(entropy) != entropy_bytes:
logging.error( logging.error(
f"Derived entropy length is {len(entropy)} bytes; expected {bytes_len} bytes." f"Derived entropy length is {len(entropy)} bytes; expected {entropy_bytes} bytes."
) )
print( print(
f"{Fore.RED}Error: Derived entropy length is {len(entropy)} bytes; expected {bytes_len} bytes." f"{Fore.RED}Error: Derived entropy length is {len(entropy)} bytes; expected {entropy_bytes} bytes."
) )
raise Bip85Error( raise Bip85Error(
f"Derived entropy length is {len(entropy)} bytes; expected {bytes_len} bytes." f"Derived entropy length is {len(entropy)} bytes; expected {entropy_bytes} bytes."
) )
logging.debug(f"Derived entropy: {entropy.hex()}") logging.debug(f"Derived entropy: {entropy.hex()}")
@@ -107,14 +123,17 @@ class BIP85:
raise Bip85Error(f"Error deriving entropy: {e}") raise Bip85Error(f"Error deriving entropy: {e}")
def derive_mnemonic(self, index: int, words_num: int) -> str: def derive_mnemonic(self, index: int, words_num: int) -> str:
bytes_len = {12: 16, 18: 24, 24: 32}.get(words_num) entropy_bytes = {12: 16, 18: 24, 24: 32}.get(words_num)
if not bytes_len: if not entropy_bytes:
logging.error(f"Unsupported number of words: {words_num}") logging.error(f"Unsupported number of words: {words_num}")
print(f"{Fore.RED}Error: Unsupported number of words: {words_num}") print(f"{Fore.RED}Error: Unsupported number of words: {words_num}")
raise Bip85Error(f"Unsupported number of words: {words_num}") raise Bip85Error(f"Unsupported number of words: {words_num}")
entropy = self.derive_entropy( entropy = self.derive_entropy(
index=index, bytes_len=bytes_len, app_no=39, words_len=words_num index=index,
entropy_bytes=entropy_bytes,
app_no=39,
word_count=words_num,
) )
try: try:
mnemonic = Bip39MnemonicGenerator(Bip39Languages.ENGLISH).FromEntropy( mnemonic = Bip39MnemonicGenerator(Bip39Languages.ENGLISH).FromEntropy(
@@ -130,7 +149,7 @@ class BIP85:
def derive_symmetric_key(self, index: int = 0, app_no: int = 2) -> bytes: def derive_symmetric_key(self, index: int = 0, app_no: int = 2) -> bytes:
"""Derive 32 bytes of entropy for symmetric key usage.""" """Derive 32 bytes of entropy for symmetric key usage."""
try: try:
key = self.derive_entropy(index=index, bytes_len=32, app_no=app_no) key = self.derive_entropy(index=index, entropy_bytes=32, app_no=app_no)
logging.debug(f"Derived symmetric key: {key.hex()}") logging.debug(f"Derived symmetric key: {key.hex()}")
return key return key
except Exception as e: except Exception as e:

View File

@@ -85,7 +85,7 @@ class KeyManager:
# Derive entropy for Nostr key (32 bytes) # Derive entropy for Nostr key (32 bytes)
entropy_bytes = self.bip85.derive_entropy( entropy_bytes = self.bip85.derive_entropy(
index=index, index=index,
bytes_len=32, entropy_bytes=32,
app_no=NOSTR_KEY_APP_ID, app_no=NOSTR_KEY_APP_ID,
) )
@@ -102,7 +102,7 @@ class KeyManager:
"""Derive Nostr keys using the legacy application ID.""" """Derive Nostr keys using the legacy application ID."""
try: try:
entropy = self.bip85.derive_entropy( entropy = self.bip85.derive_entropy(
index=0, bytes_len=32, app_no=LEGACY_NOSTR_KEY_APP_ID index=0, entropy_bytes=32, app_no=LEGACY_NOSTR_KEY_APP_ID
) )
return Keys(priv_k=entropy.hex()) return Keys(priv_k=entropy.hex())
except Exception as e: except Exception as e:

View File

@@ -1,41 +0,0 @@
# nostr/logging_config.py
import logging
import os
# Comment out or remove the configure_logging function to avoid conflicts
# def configure_logging():
# """
# Configures logging with both file and console handlers.
# Logs include the timestamp, log level, message, filename, and line number.
# Only ERROR and higher-level messages are shown in the terminal, while all messages
# are logged in the log file.
# """
# logger = logging.getLogger()
# logger.setLevel(logging.DEBUG) # Set root logger to DEBUG
#
# # Prevent adding multiple handlers if configure_logging is called multiple times
# if not logger.handlers:
# # Create the 'logs' folder if it doesn't exist
# log_directory = 'logs'
# if not os.path.exists(log_directory):
# os.makedirs(log_directory)
#
# # Create handlers
# c_handler = logging.StreamHandler()
# f_handler = logging.FileHandler(os.path.join(log_directory, 'app.log'))
#
# # Set levels: only errors and critical messages will be shown in the console
# c_handler.setLevel(logging.ERROR)
# f_handler.setLevel(logging.DEBUG)
#
# # Create formatters and add them to handlers, include file and line number in log messages
# formatter = logging.Formatter(
# '%(asctime)s [%(levelname)s] %(message)s [%(filename)s:%(lineno)d]'
# )
# c_handler.setFormatter(formatter)
# f_handler.setFormatter(formatter)
#
# # Add handlers to the logger
# logger.addHandler(c_handler)
# logger.addHandler(f_handler)

View File

@@ -1,9 +0,0 @@
"""Placeholder utilities for Nostr.
This module is intentionally left minimal and will be expanded in future
releases as the Nostr integration grows.
"""
# The module currently provides no functionality.
# `pass` denotes the intentional absence of implementation.
pass

View File

@@ -28,7 +28,6 @@ Generated on: 2025-04-06
├── encryption_manager.py ├── encryption_manager.py
├── event_handler.py ├── event_handler.py
├── key_manager.py ├── key_manager.py
├── logging_config.py
├── utils.py ├── utils.py
├── utils/ ├── utils/
├── __init__.py ├── __init__.py
@@ -3082,52 +3081,6 @@ __all__ = ['NostrClient']
``` ```
## nostr/logging_config.py
```python
# nostr/logging_config.py
import logging
import os
# Comment out or remove the configure_logging function to avoid conflicts
# def configure_logging():
# """
# Configures logging with both file and console handlers.
# Logs include the timestamp, log level, message, filename, and line number.
# Only ERROR and higher-level messages are shown in the terminal, while all messages
# are logged in the log file.
# """
# logger = logging.getLogger()
# logger.setLevel(logging.DEBUG) # Set root logger to DEBUG
#
# # Prevent adding multiple handlers if configure_logging is called multiple times
# if not logger.handlers:
# # Create the 'logs' folder if it doesn't exist
# log_directory = 'logs'
# if not os.path.exists(log_directory):
# os.makedirs(log_directory)
#
# # Create handlers
# c_handler = logging.StreamHandler()
# f_handler = logging.FileHandler(os.path.join(log_directory, 'app.log'))
#
# # Set levels: only errors and critical messages will be shown in the console
# c_handler.setLevel(logging.ERROR)
# f_handler.setLevel(logging.DEBUG)
#
# # Create formatters and add them to handlers, include file and line number in log messages
# formatter = logging.Formatter(
# '%(asctime)s [%(levelname)s] %(message)s [%(filename)s:%(lineno)d]'
# )
# c_handler.setFormatter(formatter)
# f_handler.setFormatter(formatter)
#
# # Add handlers to the logger
# logger.addHandler(c_handler)
# logger.addHandler(f_handler)
```
## nostr/event_handler.py ## nostr/event_handler.py
```python ```python
# nostr/event_handler.py # nostr/event_handler.py

View File

@@ -478,7 +478,7 @@ def get_totp_codes(
_require_password(request, password) _require_password(request, password)
pm = _get_pm(request) pm = _get_pm(request)
entries = pm.entry_manager.list_entries( entries = pm.entry_manager.list_entries(
filter_kind=EntryType.TOTP.value, include_archived=False filter_kinds=[EntryType.TOTP.value], include_archived=False
) )
codes = [] codes = []
for idx, label, _u, _url, _arch in entries: for idx, label, _u, _url, _arch in entries:

View File

@@ -6,8 +6,10 @@ from pathlib import Path
from typing import List, Optional from typing import List, Optional
import typer import typer
import click
from .common import _get_entry_service, EntryType from .common import _get_entry_service, EntryType
from seedpass.core.entry_types import ALL_ENTRY_TYPES
from utils.clipboard import ClipboardUnavailableError from utils.clipboard import ClipboardUnavailableError
@@ -20,13 +22,20 @@ def entry_list(
sort: str = typer.Option( sort: str = typer.Option(
"index", "--sort", help="Sort by 'index', 'label', or 'updated'" "index", "--sort", help="Sort by 'index', 'label', or 'updated'"
), ),
kind: Optional[str] = typer.Option(None, "--kind", help="Filter by entry type"), kind: Optional[str] = typer.Option(
None,
"--kind",
help="Filter by entry type",
click_type=click.Choice(ALL_ENTRY_TYPES),
),
archived: bool = typer.Option(False, "--archived", help="Include archived"), archived: bool = typer.Option(False, "--archived", help="Include archived"),
) -> None: ) -> None:
"""List entries in the vault.""" """List entries in the vault."""
service = _get_entry_service(ctx) service = _get_entry_service(ctx)
entries = service.list_entries( entries = service.list_entries(
sort_by=sort, filter_kind=kind, include_archived=archived sort_by=sort,
filter_kinds=[kind] if kind else None,
include_archived=archived,
) )
for idx, label, username, url, is_archived in entries: for idx, label, username, url, is_archived in entries:
line = f"{idx}: {label}" line = f"{idx}: {label}"
@@ -43,16 +52,17 @@ def entry_list(
def entry_search( def entry_search(
ctx: typer.Context, ctx: typer.Context,
query: str, query: str,
kind: List[str] = typer.Option( kinds: List[str] = typer.Option(
None, None,
"--kind", "--kind",
"-k", "-k",
help="Filter by entry kinds (can be repeated)", help="Filter by entry kinds (can be repeated)",
click_type=click.Choice(ALL_ENTRY_TYPES),
), ),
) -> None: ) -> None:
"""Search entries.""" """Search entries."""
service = _get_entry_service(ctx) service = _get_entry_service(ctx)
kinds = list(kind) if kind else None kinds = list(kinds) if kinds else None
results = service.search_entries(query, kinds=kinds) results = service.search_entries(query, kinds=kinds)
if not results: if not results:
typer.echo("No matching entries found") typer.echo("No matching entries found")

View File

@@ -265,13 +265,13 @@ class EntryService:
def list_entries( def list_entries(
self, self,
sort_by: str = "index", sort_by: str = "index",
filter_kind: str | None = None, filter_kinds: list[str] | None = None,
include_archived: bool = False, include_archived: bool = False,
): ):
with self._lock: with self._lock:
return self._manager.entry_manager.list_entries( return self._manager.entry_manager.list_entries(
sort_by=sort_by, sort_by=sort_by,
filter_kind=filter_kind, filter_kinds=filter_kinds,
include_archived=include_archived, include_archived=include_archived,
) )

View File

@@ -33,7 +33,7 @@ from pathlib import Path
from termcolor import colored from termcolor import colored
from .migrations import LATEST_VERSION from .migrations import LATEST_VERSION
from .entry_types import EntryType from .entry_types import EntryType, ALL_ENTRY_TYPES
from .totp import TotpManager from .totp import TotpManager
from utils.fingerprint import generate_fingerprint from utils.fingerprint import generate_fingerprint
from utils.checksum import canonical_json_dumps from utils.checksum import canonical_json_dumps
@@ -461,7 +461,7 @@ class EntryManager:
seed_bytes = Bip39SeedGenerator(parent_seed).Generate() seed_bytes = Bip39SeedGenerator(parent_seed).Generate()
bip85 = BIP85(seed_bytes) bip85 = BIP85(seed_bytes)
entropy = bip85.derive_entropy(index=index, bytes_len=32) entropy = bip85.derive_entropy(index=index, entropy_bytes=32)
keys = Keys(priv_k=entropy.hex()) keys = Keys(priv_k=entropy.hex())
npub = Keys.hex_to_bech32(keys.public_key_hex(), "npub") npub = Keys.hex_to_bech32(keys.public_key_hex(), "npub")
nsec = Keys.hex_to_bech32(keys.private_key_hex(), "nsec") nsec = Keys.hex_to_bech32(keys.private_key_hex(), "nsec")
@@ -539,7 +539,7 @@ class EntryManager:
bip85 = BIP85(seed_bytes) bip85 = BIP85(seed_bytes)
key_idx = int(entry.get("index", index)) key_idx = int(entry.get("index", index))
entropy = bip85.derive_entropy(index=key_idx, bytes_len=32) entropy = bip85.derive_entropy(index=key_idx, entropy_bytes=32)
keys = Keys(priv_k=entropy.hex()) keys = Keys(priv_k=entropy.hex())
npub = Keys.hex_to_bech32(keys.public_key_hex(), "npub") npub = Keys.hex_to_bech32(keys.public_key_hex(), "npub")
nsec = Keys.hex_to_bech32(keys.private_key_hex(), "nsec") nsec = Keys.hex_to_bech32(keys.private_key_hex(), "nsec")
@@ -1076,7 +1076,7 @@ class EntryManager:
def list_entries( def list_entries(
self, self,
sort_by: str = "index", sort_by: str = "index",
filter_kind: str | None = None, filter_kinds: list[str] | None = None,
*, *,
include_archived: bool = False, include_archived: bool = False,
verbose: bool = True, verbose: bool = True,
@@ -1088,8 +1088,9 @@ class EntryManager:
sort_by: sort_by:
Field to sort by. Supported values are ``"index"``, ``"label"`` and Field to sort by. Supported values are ``"index"``, ``"label"`` and
``"updated"``. ``"updated"``.
filter_kind: filter_kinds:
Optional entry kind to restrict the results. Optional list of entry kinds to restrict the results. Defaults to
``ALL_ENTRY_TYPES``.
Archived entries are omitted unless ``include_archived`` is ``True``. Archived entries are omitted unless ``include_archived`` is ``True``.
""" """
@@ -1118,12 +1119,14 @@ class EntryManager:
sorted_items = sorted(entries_data.items(), key=sort_key) sorted_items = sorted(entries_data.items(), key=sort_key)
if filter_kinds is None:
filter_kinds = ALL_ENTRY_TYPES
filtered_items: List[Tuple[int, Dict[str, Any]]] = [] filtered_items: List[Tuple[int, Dict[str, Any]]] = []
for idx_str, entry in sorted_items: for idx_str, entry in sorted_items:
if ( if (
filter_kind is not None entry.get("type", entry.get("kind", EntryType.PASSWORD.value))
and entry.get("type", entry.get("kind", EntryType.PASSWORD.value)) not in filter_kinds
!= filter_kind
): ):
continue continue
if not include_archived and entry.get( if not include_archived and entry.get(
@@ -1371,7 +1374,7 @@ class EntryManager:
def list_all_entries( def list_all_entries(
self, self,
sort_by: str = "index", sort_by: str = "index",
filter_kind: str | None = None, filter_kinds: list[str] | None = None,
*, *,
include_archived: bool = False, include_archived: bool = False,
) -> None: ) -> None:
@@ -1379,7 +1382,7 @@ class EntryManager:
try: try:
entries = self.list_entries( entries = self.list_entries(
sort_by=sort_by, sort_by=sort_by,
filter_kind=filter_kind, filter_kinds=filter_kinds,
include_archived=include_archived, include_archived=include_archived,
) )
if not entries: if not entries:
@@ -1403,7 +1406,7 @@ class EntryManager:
def get_entry_summaries( def get_entry_summaries(
self, self,
filter_kind: str | None = None, filter_kinds: list[str] | None = None,
*, *,
include_archived: bool = False, include_archived: bool = False,
) -> list[tuple[int, str, str]]: ) -> list[tuple[int, str, str]]:
@@ -1412,10 +1415,13 @@ class EntryManager:
data = self._load_index() data = self._load_index()
entries_data = data.get("entries", {}) entries_data = data.get("entries", {})
if filter_kinds is None:
filter_kinds = ALL_ENTRY_TYPES
summaries: list[tuple[int, str, str]] = [] summaries: list[tuple[int, str, str]] = []
for idx_str, entry in entries_data.items(): for idx_str, entry in entries_data.items():
etype = entry.get("type", entry.get("kind", EntryType.PASSWORD.value)) etype = entry.get("type", entry.get("kind", EntryType.PASSWORD.value))
if filter_kind and etype != filter_kind: if etype not in filter_kinds:
continue continue
if not include_archived and entry.get( if not include_archived and entry.get(
"archived", entry.get("blacklisted", False) "archived", entry.get("blacklisted", False)

View File

@@ -0,0 +1,233 @@
from __future__ import annotations
import logging
import time
from typing import TYPE_CHECKING
from termcolor import colored
from constants import (
DEFAULT_PASSWORD_LENGTH,
MAX_PASSWORD_LENGTH,
MIN_PASSWORD_LENGTH,
)
import seedpass.core.manager as manager_module
from utils.terminal_utils import clear_header_with_notification, pause
if TYPE_CHECKING: # pragma: no cover - typing only
from .manager import PasswordManager
class EntryService:
"""Entry management operations for :class:`PasswordManager`."""
def __init__(self, manager: PasswordManager) -> None:
self.manager = manager
def handle_add_password(self) -> None:
pm = self.manager
try:
fp, parent_fp, child_fp = pm.header_fingerprint_args
clear_header_with_notification(
pm,
fp,
"Main Menu > Add Entry > Password",
parent_fingerprint=parent_fp,
child_fingerprint=child_fp,
)
def prompt_length() -> int | None:
length_input = input(
f"Enter desired password length (default {DEFAULT_PASSWORD_LENGTH}): "
).strip()
length = DEFAULT_PASSWORD_LENGTH
if length_input:
if not length_input.isdigit():
print(
colored("Error: Password length must be a number.", "red")
)
return None
length = int(length_input)
if not (MIN_PASSWORD_LENGTH <= length <= MAX_PASSWORD_LENGTH):
print(
colored(
f"Error: Password length must be between {MIN_PASSWORD_LENGTH} and {MAX_PASSWORD_LENGTH}.",
"red",
)
)
return None
return length
def finalize_entry(index: int, label: str, length: int) -> None:
pm.is_dirty = True
pm.last_update = time.time()
entry = pm.entry_manager.retrieve_entry(index)
password = pm._generate_password_for_entry(entry, index, length)
print(
colored(
f"\n[+] Password generated and indexed with ID {index}.\n",
"green",
)
)
if pm.secret_mode_enabled:
if manager_module.copy_to_clipboard(
password, pm.clipboard_clear_delay
):
print(
colored(
f"[+] Password copied to clipboard. Will clear in {pm.clipboard_clear_delay} seconds.",
"green",
)
)
else:
print(colored(f"Password for {label}: {password}\n", "yellow"))
try:
pm.start_background_vault_sync()
logging.info(
"Encrypted index posted to Nostr after entry addition."
)
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,
)
pause()
mode = input("Choose mode: [Q]uick or [A]dvanced? ").strip().lower()
website_name = input("Enter the label or website name: ").strip()
if not website_name:
print(colored("Error: Label cannot be empty.", "red"))
return
username = input("Enter the username (optional): ").strip()
url = input("Enter the URL (optional): ").strip()
if mode.startswith("q"):
length = prompt_length()
if length is None:
return
include_special_input = (
input("Include special characters? (Y/n): ").strip().lower()
)
include_special_chars: bool | None = None
if include_special_input:
include_special_chars = include_special_input != "n"
index = pm.entry_manager.add_entry(
website_name,
length,
username,
url,
include_special_chars=include_special_chars,
)
finalize_entry(index, website_name, length)
return
notes = input("Enter notes (optional): ").strip()
tags_input = input("Enter tags (comma-separated, optional): ").strip()
tags = (
[t.strip() for t in tags_input.split(",") if t.strip()]
if tags_input
else []
)
custom_fields: list[dict[str, object]] = []
while True:
add_field = input("Add custom field? (y/N): ").strip().lower()
if add_field != "y":
break
label = input(" Field label: ").strip()
value = input(" Field value: ").strip()
hidden = input(" Hidden field? (y/N): ").strip().lower() == "y"
custom_fields.append(
{"label": label, "value": value, "is_hidden": hidden}
)
length = prompt_length()
if length is None:
return
include_special_input = (
input("Include special characters? (Y/n): ").strip().lower()
)
include_special_chars: bool | None = None
if include_special_input:
include_special_chars = include_special_input != "n"
allowed_special_chars = input(
"Allowed special characters (leave blank for default): "
).strip()
if not allowed_special_chars:
allowed_special_chars = None
special_mode = input("Special character mode (safe/leave blank): ").strip()
if not special_mode:
special_mode = None
exclude_ambiguous_input = (
input("Exclude ambiguous characters? (y/N): ").strip().lower()
)
exclude_ambiguous: bool | None = None
if exclude_ambiguous_input:
exclude_ambiguous = exclude_ambiguous_input == "y"
min_uppercase_input = input(
"Minimum uppercase letters (blank for default): "
).strip()
if min_uppercase_input and not min_uppercase_input.isdigit():
print(colored("Error: Minimum uppercase must be a number.", "red"))
return
min_uppercase = int(min_uppercase_input) if min_uppercase_input else None
min_lowercase_input = input(
"Minimum lowercase letters (blank for default): "
).strip()
if min_lowercase_input and not min_lowercase_input.isdigit():
print(colored("Error: Minimum lowercase must be a number.", "red"))
return
min_lowercase = int(min_lowercase_input) if min_lowercase_input else None
min_digits_input = input("Minimum digits (blank for default): ").strip()
if min_digits_input and not min_digits_input.isdigit():
print(colored("Error: Minimum digits must be a number.", "red"))
return
min_digits = int(min_digits_input) if min_digits_input else None
min_special_input = input(
"Minimum special characters (blank for default): "
).strip()
if min_special_input and not min_special_input.isdigit():
print(colored("Error: Minimum special must be a number.", "red"))
return
min_special = int(min_special_input) if min_special_input else None
index = pm.entry_manager.add_entry(
website_name,
length,
username,
url,
archived=False,
notes=notes,
custom_fields=custom_fields,
tags=tags,
include_special_chars=include_special_chars,
allowed_special_chars=allowed_special_chars,
special_mode=special_mode,
exclude_ambiguous=exclude_ambiguous,
min_uppercase=min_uppercase,
min_lowercase=min_lowercase,
min_digits=min_digits,
min_special=min_special,
)
finalize_entry(index, website_name, length)
except Exception as e: # pragma: no cover - defensive
logging.error(f"Error during password generation: {e}", exc_info=True)
print(colored(f"Error: Failed to generate password: {e}", "red"))
pause()

View File

@@ -15,3 +15,7 @@ class EntryType(str, Enum):
NOSTR = "nostr" NOSTR = "nostr"
KEY_VALUE = "key_value" KEY_VALUE = "key_value"
MANAGED_ACCOUNT = "managed_account" MANAGED_ACCOUNT = "managed_account"
# List of all entry type values for convenience
ALL_ENTRY_TYPES = [e.value for e in EntryType]

View File

@@ -24,6 +24,7 @@ import threading
import queue import queue
from dataclasses import dataclass from dataclasses import dataclass
import dataclasses import dataclasses
from functools import wraps
from termcolor import colored from termcolor import colored
from utils.color_scheme import color_text from utils.color_scheme import color_text
from utils.input_utils import timed_input from utils.input_utils import timed_input
@@ -72,6 +73,7 @@ from utils.fingerprint import generate_fingerprint
from utils.atomic_write import atomic_write from utils.atomic_write import atomic_write
from constants import MIN_HEALTHY_RELAYS from constants import MIN_HEALTHY_RELAYS
from .migrations import LATEST_VERSION from .migrations import LATEST_VERSION
from ..errors import VaultLockedError
from constants import ( from constants import (
APP_DIR, APP_DIR,
@@ -105,6 +107,9 @@ from nostr.snapshot import MANIFEST_ID_PREFIX
from .config_manager import ConfigManager from .config_manager import ConfigManager
from .state_manager import StateManager from .state_manager import StateManager
from .stats_manager import StatsManager from .stats_manager import StatsManager
from .menu_handler import MenuHandler
from .profile_service import ProfileService
from .entry_service import EntryService
# Instantiate the logger # Instantiate the logger
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
@@ -161,6 +166,18 @@ class Notification:
level: str = "INFO" level: str = "INFO"
def requires_unlocked(func):
"""Decorator to ensure the vault is unlocked before proceeding."""
@wraps(func)
def wrapper(self, *args, **kwargs):
if getattr(self, "is_locked", False):
raise VaultLockedError("Vault is locked")
return func(self, *args, **kwargs)
return wrapper
class AuthGuard: class AuthGuard:
"""Helper to enforce inactivity timeouts.""" """Helper to enforce inactivity timeouts."""
@@ -225,6 +242,7 @@ class PasswordManager:
self.last_update: float = time.time() self.last_update: float = time.time()
self.last_activity: float = time.time() self.last_activity: float = time.time()
self.locked: bool = False self.locked: bool = False
self.is_locked: bool = False
self.inactivity_timeout: float = INACTIVITY_TIMEOUT self.inactivity_timeout: float = INACTIVITY_TIMEOUT
self.secret_mode_enabled: bool = False self.secret_mode_enabled: bool = False
self.clipboard_clear_delay: int = 45 self.clipboard_clear_delay: int = 45
@@ -237,6 +255,16 @@ class PasswordManager:
self.last_sync_ts: int = 0 self.last_sync_ts: int = 0
self.auth_guard = AuthGuard(self) self.auth_guard = AuthGuard(self)
# Service composition
self._menu_handler: MenuHandler | None = None
self._profile_service: ProfileService | None = None
self._entry_service: EntryService | None = None
# Initialize service instances
self.menu_handler
self.profile_service
self.entry_service
# Initialize the fingerprint manager first # Initialize the fingerprint manager first
self.initialize_fingerprint_manager() self.initialize_fingerprint_manager()
@@ -251,15 +279,19 @@ class PasswordManager:
self.fingerprint_manager.get_current_fingerprint_dir() self.fingerprint_manager.get_current_fingerprint_dir()
) )
def get_bip85_entropy(self, purpose: int, index: int, bytes_len: int = 32) -> bytes: @requires_unlocked
def get_bip85_entropy(
self, purpose: int, index: int, entropy_bytes: int = 32
) -> bytes:
"""Return deterministic entropy via the cached BIP-85 function.""" """Return deterministic entropy via the cached BIP-85 function."""
if self.bip85 is None: if self.bip85 is None:
raise RuntimeError("BIP-85 is not initialized") raise RuntimeError("BIP-85 is not initialized")
return self.bip85.derive_entropy( return self.bip85.derive_entropy(
index=index, bytes_len=bytes_len, app_no=purpose index=index, entropy_bytes=entropy_bytes, app_no=purpose
) )
@requires_unlocked
def clear_bip85_cache(self) -> None: def clear_bip85_cache(self) -> None:
"""Clear the internal BIP-85 cache.""" """Clear the internal BIP-85 cache."""
@@ -373,19 +405,31 @@ class PasswordManager:
logger.warning("Background task failed: %s", exc) logger.warning("Background task failed: %s", exc)
self.notify(f"Background task failed: {exc}", level="WARNING") self.notify(f"Background task failed: {exc}", level="WARNING")
@property
def menu_handler(self) -> MenuHandler:
if getattr(self, "_menu_handler", None) is None:
self._menu_handler = MenuHandler(self)
return self._menu_handler
@property
def profile_service(self) -> ProfileService:
if getattr(self, "_profile_service", None) is None:
self._profile_service = ProfileService(self)
return self._profile_service
@property
def entry_service(self) -> EntryService:
if getattr(self, "is_locked", False):
raise VaultLockedError("Vault is locked")
if getattr(self, "_entry_service", None) is None:
self._entry_service = EntryService(self)
return self._entry_service
def lock_vault(self) -> None: def lock_vault(self) -> None:
"""Clear sensitive information from memory.""" """Clear sensitive information from memory."""
if self.entry_manager is not None: if self.entry_manager is not None:
self.entry_manager.clear_cache() self.entry_manager.clear_cache()
self.parent_seed = None self.is_locked = True
self.encryption_manager = None
self.entry_manager = None
self.password_generator = None
self.backup_manager = None
self.vault = None
self.bip85 = None
self.nostr_client = None
self.config_manager = None
self.locked = True self.locked = True
bus.publish("vault_locked") bus.publish("vault_locked")
@@ -410,6 +454,7 @@ class PasswordManager:
self.setup_encryption_manager(self.fingerprint_dir, password) self.setup_encryption_manager(self.fingerprint_dir, password)
self.initialize_bip85() self.initialize_bip85()
self.initialize_managers() self.initialize_managers()
self.is_locked = False
self.locked = False self.locked = False
self.update_activity() self.update_activity()
if ( if (
@@ -715,104 +760,11 @@ class PasswordManager:
print(colored(f"Error: Failed to load parent seed: {e}", "red")) print(colored(f"Error: Failed to load parent seed: {e}", "red"))
sys.exit(1) sys.exit(1)
@requires_unlocked
def handle_switch_fingerprint(self, *, password: Optional[str] = None) -> bool: def handle_switch_fingerprint(self, *, password: Optional[str] = None) -> bool:
""" return self.profile_service.handle_switch_fingerprint(password=password)
Handles switching to a different seed profile.
Returns:
bool: True if switch was successful, False otherwise.
"""
try:
print(colored("\nAvailable Seed Profiles:", "cyan"))
fingerprints = self.fingerprint_manager.list_fingerprints()
for idx, fp in enumerate(fingerprints, start=1):
display = (
self.fingerprint_manager.display_name(fp)
if hasattr(self.fingerprint_manager, "display_name")
else fp
)
print(colored(f"{idx}. {display}", "cyan"))
choice = input("Select a seed profile by number to switch: ").strip()
if not choice.isdigit() or not (1 <= int(choice) <= len(fingerprints)):
print(colored("Invalid selection. Returning to main menu.", "red"))
return False # Return False to indicate failure
selected_fingerprint = fingerprints[int(choice) - 1]
self.fingerprint_manager.current_fingerprint = selected_fingerprint
self.current_fingerprint = selected_fingerprint
if not getattr(self, "manifest_id", None):
self.manifest_id = f"{MANIFEST_ID_PREFIX}{selected_fingerprint}"
# Update fingerprint directory
self.fingerprint_dir = (
self.fingerprint_manager.get_current_fingerprint_dir()
)
if not self.fingerprint_dir:
print(
colored(
f"Error: Seed profile directory for {selected_fingerprint} not found.",
"red",
)
)
return False # Return False to indicate failure
# Prompt for master password for the selected seed profile
if password is None:
password = prompt_existing_password(
"Enter the master password for the selected seed profile: "
)
# Set up the encryption manager with the new password and seed profile directory
if not self.setup_encryption_manager(
self.fingerprint_dir, password, exit_on_fail=False
):
return False
# Initialize BIP85 and other managers
self.initialize_bip85()
self.initialize_managers()
self.start_background_sync()
print(colored(f"Switched to seed profile {selected_fingerprint}.", "green"))
# Re-initialize NostrClient with the new fingerprint
try:
self.nostr_client = NostrClient(
encryption_manager=self.encryption_manager,
fingerprint=self.current_fingerprint,
config_manager=getattr(self, "config_manager", None),
parent_seed=getattr(self, "parent_seed", None),
)
if getattr(self, "manifest_id", None) and hasattr(
self.nostr_client, "_state_lock"
):
from nostr.backup_models import Manifest
with self.nostr_client._state_lock:
self.nostr_client.current_manifest_id = self.manifest_id
self.nostr_client.current_manifest = Manifest(
ver=1,
algo="gzip",
chunks=[],
delta_since=self.delta_since or None,
)
logging.info(
f"NostrClient re-initialized with seed profile {self.current_fingerprint}."
)
except Exception as e:
logging.error(f"Failed to re-initialize NostrClient: {e}")
print(
colored(f"Error: Failed to re-initialize NostrClient: {e}", "red")
)
return False
return True # Return True to indicate success
except Exception as e:
logging.error(f"Error during seed profile switching: {e}", exc_info=True)
print(colored(f"Error: Failed to switch seed profiles: {e}", "red"))
return False # Return False to indicate failure
@requires_unlocked
def load_managed_account(self, index: int) -> None: def load_managed_account(self, index: int) -> None:
"""Load a managed account derived from the current seed profile.""" """Load a managed account derived from the current seed profile."""
if not self.entry_manager or not self.parent_seed: if not self.entry_manager or not self.parent_seed:
@@ -841,6 +793,7 @@ class PasswordManager:
self.update_activity() self.update_activity()
self.start_background_sync() self.start_background_sync()
@requires_unlocked
def exit_managed_account(self) -> None: def exit_managed_account(self) -> None:
"""Return to the parent seed profile if one is on the stack.""" """Return to the parent seed profile if one is on the stack."""
if not self.profile_stack: if not self.profile_stack:
@@ -1105,6 +1058,7 @@ class PasswordManager:
print(colored("Error: Invalid BIP-85 seed phrase.", "red")) print(colored("Error: Invalid BIP-85 seed phrase.", "red"))
sys.exit(1) sys.exit(1)
@requires_unlocked
def generate_new_seed(self) -> Optional[str]: def generate_new_seed(self) -> Optional[str]:
""" """
Generates a new BIP-85 seed, displays it to the user, and prompts for confirmation before saving. Generates a new BIP-85 seed, displays it to the user, and prompts for confirmation before saving.
@@ -1210,6 +1164,7 @@ class PasswordManager:
print(colored(f"Error: Failed to generate BIP-85 seed: {e}", "red")) print(colored(f"Error: Failed to generate BIP-85 seed: {e}", "red"))
sys.exit(1) sys.exit(1)
@requires_unlocked
def save_and_encrypt_seed( def save_and_encrypt_seed(
self, seed: str, fingerprint_dir: Path, *, password: Optional[str] = None self, seed: str, fingerprint_dir: Path, *, password: Optional[str] = None
) -> None: ) -> None:
@@ -1290,11 +1245,13 @@ class PasswordManager:
self._bip85_cache = {} self._bip85_cache = {}
orig_derive = self.bip85.derive_entropy orig_derive = self.bip85.derive_entropy
def cached_derive(index: int, bytes_len: int, app_no: int = 39) -> bytes: def cached_derive(
index: int, entropy_bytes: int, app_no: int = 39
) -> bytes:
key = (app_no, index) key = (app_no, index)
if key not in self._bip85_cache: if key not in self._bip85_cache:
self._bip85_cache[key] = orig_derive( self._bip85_cache[key] = orig_derive(
index=index, bytes_len=bytes_len, app_no=app_no index=index, entropy_bytes=entropy_bytes, app_no=app_no
) )
return self._bip85_cache[key] return self._bip85_cache[key]
@@ -1791,216 +1748,10 @@ class PasswordManager:
print(colored("Failed to download vault from Nostr.", "red")) print(colored("Failed to download vault from Nostr.", "red"))
else: else:
self.notify("Starting with a new, empty vault.", level="INFO") self.notify("Starting with a new, empty vault.", level="INFO")
return
def handle_add_password(self) -> None: def handle_add_password(self) -> None:
try: self.entry_service.handle_add_password()
fp, parent_fp, child_fp = self.header_fingerprint_args
clear_header_with_notification(
self,
fp,
"Main Menu > Add Entry > Password",
parent_fingerprint=parent_fp,
child_fingerprint=child_fp,
)
def prompt_length() -> int | None:
length_input = input(
f"Enter desired password length (default {DEFAULT_PASSWORD_LENGTH}): "
).strip()
length = DEFAULT_PASSWORD_LENGTH
if length_input:
if not length_input.isdigit():
print(
colored("Error: Password length must be a number.", "red")
)
return None
length = int(length_input)
if not (MIN_PASSWORD_LENGTH <= length <= MAX_PASSWORD_LENGTH):
print(
colored(
f"Error: Password length must be between {MIN_PASSWORD_LENGTH} and {MAX_PASSWORD_LENGTH}.",
"red",
)
)
return None
return length
def finalize_entry(index: int, label: str, length: int) -> None:
# Mark database as dirty for background sync
self.is_dirty = True
self.last_update = time.time()
# Generate the password using the assigned index
entry = self.entry_manager.retrieve_entry(index)
password = self._generate_password_for_entry(entry, index, length)
# Provide user feedback
print(
colored(
f"\n[+] Password generated and indexed with ID {index}.\n",
"green",
)
)
if self.secret_mode_enabled:
if copy_to_clipboard(password, self.clipboard_clear_delay):
print(
colored(
f"[+] Password copied to clipboard. Will clear in {self.clipboard_clear_delay} seconds.",
"green",
)
)
else:
print(colored(f"Password for {label}: {password}\n", "yellow"))
# Automatically push the updated encrypted index to Nostr so the
# latest changes are backed up remotely.
try:
self.start_background_vault_sync()
logging.info(
"Encrypted index posted to Nostr after entry addition."
)
except Exception as nostr_error:
logging.error(
f"Failed to post updated index to Nostr: {nostr_error}",
exc_info=True,
)
pause()
mode = input("Choose mode: [Q]uick or [A]dvanced? ").strip().lower()
website_name = input("Enter the label or website name: ").strip()
if not website_name:
print(colored("Error: Label cannot be empty.", "red"))
return
username = input("Enter the username (optional): ").strip()
url = input("Enter the URL (optional): ").strip()
if mode.startswith("q"):
length = prompt_length()
if length is None:
return
include_special_input = (
input("Include special characters? (Y/n): ").strip().lower()
)
include_special_chars: bool | None = None
if include_special_input:
include_special_chars = include_special_input != "n"
index = self.entry_manager.add_entry(
website_name,
length,
username,
url,
include_special_chars=include_special_chars,
)
finalize_entry(index, website_name, length)
return
notes = input("Enter notes (optional): ").strip()
tags_input = input("Enter tags (comma-separated, optional): ").strip()
tags = (
[t.strip() for t in tags_input.split(",") if t.strip()]
if tags_input
else []
)
custom_fields: list[dict[str, object]] = []
while True:
add_field = input("Add custom field? (y/N): ").strip().lower()
if add_field != "y":
break
label = input(" Field label: ").strip()
value = input(" Field value: ").strip()
hidden = input(" Hidden field? (y/N): ").strip().lower() == "y"
custom_fields.append(
{"label": label, "value": value, "is_hidden": hidden}
)
length = prompt_length()
if length is None:
return
include_special_input = (
input("Include special characters? (Y/n): ").strip().lower()
)
include_special_chars: bool | None = None
if include_special_input:
include_special_chars = include_special_input != "n"
allowed_special_chars = input(
"Allowed special characters (leave blank for default): "
).strip()
if not allowed_special_chars:
allowed_special_chars = None
special_mode = input("Special character mode (safe/leave blank): ").strip()
if not special_mode:
special_mode = None
exclude_ambiguous_input = (
input("Exclude ambiguous characters? (y/N): ").strip().lower()
)
exclude_ambiguous: bool | None = None
if exclude_ambiguous_input:
exclude_ambiguous = exclude_ambiguous_input == "y"
min_uppercase_input = input(
"Minimum uppercase letters (blank for default): "
).strip()
if min_uppercase_input and not min_uppercase_input.isdigit():
print(colored("Error: Minimum uppercase must be a number.", "red"))
return
min_uppercase = int(min_uppercase_input) if min_uppercase_input else None
min_lowercase_input = input(
"Minimum lowercase letters (blank for default): "
).strip()
if min_lowercase_input and not min_lowercase_input.isdigit():
print(colored("Error: Minimum lowercase must be a number.", "red"))
return
min_lowercase = int(min_lowercase_input) if min_lowercase_input else None
min_digits_input = input("Minimum digits (blank for default): ").strip()
if min_digits_input and not min_digits_input.isdigit():
print(colored("Error: Minimum digits must be a number.", "red"))
return
min_digits = int(min_digits_input) if min_digits_input else None
min_special_input = input(
"Minimum special characters (blank for default): "
).strip()
if min_special_input and not min_special_input.isdigit():
print(colored("Error: Minimum special must be a number.", "red"))
return
min_special = int(min_special_input) if min_special_input else None
index = self.entry_manager.add_entry(
website_name,
length,
username,
url,
archived=False,
notes=notes,
custom_fields=custom_fields,
tags=tags,
include_special_chars=include_special_chars,
allowed_special_chars=allowed_special_chars,
special_mode=special_mode,
exclude_ambiguous=exclude_ambiguous,
min_uppercase=min_uppercase,
min_lowercase=min_lowercase,
min_digits=min_digits,
min_special=min_special,
)
finalize_entry(index, website_name, length)
except Exception as e:
logging.error(f"Error during password generation: {e}", exc_info=True)
print(colored(f"Error: Failed to generate password: {e}", "red"))
pause()
def handle_add_totp(self) -> None: def handle_add_totp(self) -> None:
"""Add a TOTP entry either derived from the seed or imported.""" """Add a TOTP entry either derived from the seed or imported."""
@@ -2980,14 +2731,14 @@ class PasswordManager:
from bip_utils import Bip39SeedGenerator from bip_utils import Bip39SeedGenerator
words = int(entry.get("word_count", entry.get("words", 24))) words = int(entry.get("word_count", entry.get("words", 24)))
bytes_len = {12: 16, 18: 24, 24: 32}.get(words, 32) entropy_bytes = {12: 16, 18: 24, 24: 32}.get(words, 32)
seed_bytes = Bip39SeedGenerator(self.parent_seed).Generate() seed_bytes = Bip39SeedGenerator(self.parent_seed).Generate()
bip85 = BIP85(seed_bytes) bip85 = BIP85(seed_bytes)
entropy = bip85.derive_entropy( entropy = bip85.derive_entropy(
index=int(entry.get("index", index)), index=int(entry.get("index", index)),
bytes_len=bytes_len, entropy_bytes=entropy_bytes,
app_no=39, app_no=39,
words_len=words, word_count=words,
) )
print(color_text(f"Entropy: {entropy.hex()}", "deterministic")) print(color_text(f"Entropy: {entropy.hex()}", "deterministic"))
except Exception as e: # pragma: no cover - best effort except Exception as e: # pragma: no cover - best effort
@@ -3936,85 +3687,7 @@ class PasswordManager:
print("-" * 40) print("-" * 40)
def handle_list_entries(self) -> None: def handle_list_entries(self) -> None:
"""List entries and optionally show details.""" self.menu_handler.handle_list_entries()
try:
while True:
fp, parent_fp, child_fp = self.header_fingerprint_args
clear_header_with_notification(
self,
fp,
"Main Menu > List Entries",
parent_fingerprint=parent_fp,
child_fingerprint=child_fp,
)
print(color_text("\nList Entries:", "menu"))
print(color_text("1. All", "menu"))
print(color_text("2. Passwords", "menu"))
print(color_text("3. 2FA (TOTP)", "menu"))
print(color_text("4. SSH Key", "menu"))
print(color_text("5. Seed Phrase", "menu"))
print(color_text("6. Nostr Key Pair", "menu"))
print(color_text("7. PGP", "menu"))
print(color_text("8. Key/Value", "menu"))
print(color_text("9. Managed Account", "menu"))
choice = input("Select entry type or press Enter to go back: ").strip()
if choice == "1":
filter_kind = None
elif choice == "2":
filter_kind = EntryType.PASSWORD.value
elif choice == "3":
filter_kind = EntryType.TOTP.value
elif choice == "4":
filter_kind = EntryType.SSH.value
elif choice == "5":
filter_kind = EntryType.SEED.value
elif choice == "6":
filter_kind = EntryType.NOSTR.value
elif choice == "7":
filter_kind = EntryType.PGP.value
elif choice == "8":
filter_kind = EntryType.KEY_VALUE.value
elif choice == "9":
filter_kind = EntryType.MANAGED_ACCOUNT.value
elif not choice:
return
else:
print(colored("Invalid choice.", "red"))
continue
while True:
summaries = self.entry_manager.get_entry_summaries(
filter_kind, include_archived=False
)
if not summaries:
break
fp, parent_fp, child_fp = self.header_fingerprint_args
clear_header_with_notification(
self,
fp,
"Main Menu > List Entries",
parent_fingerprint=parent_fp,
child_fingerprint=child_fp,
)
print(colored("\n[+] Entries:\n", "green"))
for idx, etype, label in summaries:
if filter_kind is None:
display_type = etype.capitalize()
print(colored(f"{idx}. {display_type} - {label}", "cyan"))
else:
print(colored(f"{idx}. {label}", "cyan"))
idx_input = input(
"Enter index to view details or press Enter to go back: "
).strip()
if not idx_input:
break
if not idx_input.isdigit():
print(colored("Invalid index.", "red"))
continue
self.show_entry_details_by_index(int(idx_input))
except Exception as e:
logging.error(f"Failed to list entries: {e}", exc_info=True)
print(colored(f"Error: Failed to list entries: {e}", "red"))
def delete_entry(self) -> None: def delete_entry(self) -> None:
"""Deletes an entry from the password index.""" """Deletes an entry from the password index."""
@@ -4139,93 +3812,7 @@ class PasswordManager:
print(colored(f"Error: Failed to view archived entries: {e}", "red")) print(colored(f"Error: Failed to view archived entries: {e}", "red"))
def handle_display_totp_codes(self) -> None: def handle_display_totp_codes(self) -> None:
"""Display all stored TOTP codes with a countdown progress bar.""" self.menu_handler.handle_display_totp_codes()
try:
fp, parent_fp, child_fp = self.header_fingerprint_args
clear_header_with_notification(
self,
fp,
"Main Menu > 2FA Codes",
parent_fingerprint=parent_fp,
child_fingerprint=child_fp,
)
data = self.entry_manager.vault.load_index()
entries = data.get("entries", {})
totp_list: list[tuple[str, int, int, bool]] = []
for idx_str, entry in entries.items():
if self._entry_type_str(
entry
) == EntryType.TOTP.value and not entry.get(
"archived", entry.get("blacklisted", False)
):
label = entry.get("label", "")
period = int(entry.get("period", 30))
imported = "secret" in entry
totp_list.append((label, int(idx_str), period, imported))
if not totp_list:
self.notify("No 2FA entries found.", level="WARNING")
return
totp_list.sort(key=lambda t: t[0].lower())
print(colored("Press Enter to return to the menu.", "cyan"))
while True:
fp, parent_fp, child_fp = self.header_fingerprint_args
clear_header_with_notification(
self,
fp,
"Main Menu > 2FA Codes",
parent_fingerprint=parent_fp,
child_fingerprint=child_fp,
)
print(colored("Press Enter to return to the menu.", "cyan"))
generated = [t for t in totp_list if not t[3]]
imported_list = [t for t in totp_list if t[3]]
if generated:
print(colored("\nGenerated 2FA Codes:", "green"))
for label, idx, period, _ in generated:
code = self.entry_manager.get_totp_code(idx, self.parent_seed)
remaining = self.entry_manager.get_totp_time_remaining(idx)
filled = int(20 * (period - remaining) / period)
bar = "[" + "#" * filled + "-" * (20 - filled) + "]"
if self.secret_mode_enabled:
if copy_to_clipboard(code, self.clipboard_clear_delay):
print(
f"[{idx}] {label}: [HIDDEN] {bar} {remaining:2d}s - copied to clipboard"
)
else:
print(
f"[{idx}] {label}: {color_text(code, 'deterministic')} {bar} {remaining:2d}s"
)
if imported_list:
print(colored("\nImported 2FA Codes:", "green"))
for label, idx, period, _ in imported_list:
code = self.entry_manager.get_totp_code(idx, self.parent_seed)
remaining = self.entry_manager.get_totp_time_remaining(idx)
filled = int(20 * (period - remaining) / period)
bar = "[" + "#" * filled + "-" * (20 - filled) + "]"
if self.secret_mode_enabled:
if copy_to_clipboard(code, self.clipboard_clear_delay):
print(
f"[{idx}] {label}: [HIDDEN] {bar} {remaining:2d}s - copied to clipboard"
)
else:
print(
f"[{idx}] {label}: {color_text(code, 'imported')} {bar} {remaining:2d}s"
)
sys.stdout.flush()
try:
user_input = timed_input("", 1)
if user_input.strip() == "" or user_input.strip().lower() == "b":
break
except TimeoutError:
pass
except KeyboardInterrupt:
print()
break
except Exception as e:
logging.error(f"Error displaying TOTP codes: {e}", exc_info=True)
print(colored(f"Error: Failed to display TOTP codes: {e}", "red"))
def handle_verify_checksum(self) -> None: def handle_verify_checksum(self) -> None:
""" """

View File

@@ -0,0 +1,179 @@
from __future__ import annotations
import logging
import sys
from typing import TYPE_CHECKING
from termcolor import colored
from .entry_types import EntryType, ALL_ENTRY_TYPES
import seedpass.core.manager as manager_module
from utils.color_scheme import color_text
from utils.terminal_utils import clear_header_with_notification
if TYPE_CHECKING: # pragma: no cover - typing only
from .manager import PasswordManager
class MenuHandler:
"""Handle interactive menu operations for :class:`PasswordManager`."""
def __init__(self, manager: PasswordManager) -> None:
self.manager = manager
def handle_list_entries(self) -> None:
"""List entries and optionally show details."""
pm = self.manager
try:
while True:
fp, parent_fp, child_fp = pm.header_fingerprint_args
clear_header_with_notification(
pm,
fp,
"Main Menu > List Entries",
parent_fingerprint=parent_fp,
child_fingerprint=child_fp,
)
print(color_text("\nList Entries:", "menu"))
print(color_text("1. All", "menu"))
option_map: dict[str, str] = {}
for i, etype in enumerate(ALL_ENTRY_TYPES, start=2):
label = etype.replace("_", " ").title()
print(color_text(f"{i}. {label}", "menu"))
option_map[str(i)] = etype
choice = input("Select entry type or press Enter to go back: ").strip()
if choice == "1":
filter_kinds = None
elif choice in option_map:
filter_kinds = [option_map[choice]]
elif not choice:
return
else:
print(colored("Invalid choice.", "red"))
continue
while True:
summaries = pm.entry_manager.get_entry_summaries(
filter_kinds, include_archived=False
)
if not summaries:
break
fp, parent_fp, child_fp = pm.header_fingerprint_args
clear_header_with_notification(
pm,
fp,
"Main Menu > List Entries",
parent_fingerprint=parent_fp,
child_fingerprint=child_fp,
)
print(colored("\n[+] Entries:\n", "green"))
for idx, etype, label in summaries:
if filter_kinds is None:
display_type = etype.capitalize()
print(colored(f"{idx}. {display_type} - {label}", "cyan"))
else:
print(colored(f"{idx}. {label}", "cyan"))
idx_input = input(
"Enter index to view details or press Enter to go back: "
).strip()
if not idx_input:
break
if not idx_input.isdigit():
print(colored("Invalid index.", "red"))
continue
pm.show_entry_details_by_index(int(idx_input))
except Exception as e: # pragma: no cover - defensive
logging.error(f"Failed to list entries: {e}", exc_info=True)
print(colored(f"Error: Failed to list entries: {e}", "red"))
def handle_display_totp_codes(self) -> None:
"""Display all stored TOTP codes with a countdown progress bar."""
pm = self.manager
try:
fp, parent_fp, child_fp = pm.header_fingerprint_args
clear_header_with_notification(
pm,
fp,
"Main Menu > 2FA Codes",
parent_fingerprint=parent_fp,
child_fingerprint=child_fp,
)
data = pm.entry_manager.vault.load_index()
entries = data.get("entries", {})
totp_list: list[tuple[str, int, int, bool]] = []
for idx_str, entry in entries.items():
if pm._entry_type_str(entry) == EntryType.TOTP.value and not entry.get(
"archived", entry.get("blacklisted", False)
):
label = entry.get("label", "")
period = int(entry.get("period", 30))
imported = "secret" in entry
totp_list.append((label, int(idx_str), period, imported))
if not totp_list:
pm.notify("No 2FA entries found.", level="WARNING")
return
totp_list.sort(key=lambda t: t[0].lower())
print(colored("Press Enter to return to the menu.", "cyan"))
while True:
fp, parent_fp, child_fp = pm.header_fingerprint_args
clear_header_with_notification(
pm,
fp,
"Main Menu > 2FA Codes",
parent_fingerprint=parent_fp,
child_fingerprint=child_fp,
)
print(colored("Press Enter to return to the menu.", "cyan"))
generated = [t for t in totp_list if not t[3]]
imported_list = [t for t in totp_list if t[3]]
if generated:
print(colored("\nGenerated 2FA Codes:", "green"))
for label, idx, period, _ in generated:
code = pm.entry_manager.get_totp_code(idx, pm.parent_seed)
remaining = pm.entry_manager.get_totp_time_remaining(idx)
filled = int(20 * (period - remaining) / period)
bar = "[" + "#" * filled + "-" * (20 - filled) + "]"
if pm.secret_mode_enabled:
if manager_module.copy_to_clipboard(
code, pm.clipboard_clear_delay
):
print(
f"[{idx}] {label}: [HIDDEN] {bar} {remaining:2d}s - copied to clipboard"
)
else:
print(
f"[{idx}] {label}: {color_text(code, 'deterministic')} {bar} {remaining:2d}s"
)
if imported_list:
print(colored("\nImported 2FA Codes:", "green"))
for label, idx, period, _ in imported_list:
code = pm.entry_manager.get_totp_code(idx, pm.parent_seed)
remaining = pm.entry_manager.get_totp_time_remaining(idx)
filled = int(20 * (period - remaining) / period)
bar = "[" + "#" * filled + "-" * (20 - filled) + "]"
if pm.secret_mode_enabled:
if manager_module.copy_to_clipboard(
code, pm.clipboard_clear_delay
):
print(
f"[{idx}] {label}: [HIDDEN] {bar} {remaining:2d}s - copied to clipboard"
)
else:
print(
f"[{idx}] {label}: {color_text(code, 'imported')} {bar} {remaining:2d}s"
)
sys.stdout.flush()
try:
user_input = manager_module.timed_input("", 1)
if user_input.strip() == "" or user_input.strip().lower() == "b":
break
except TimeoutError:
pass
except KeyboardInterrupt:
print()
break
except Exception as e: # pragma: no cover - defensive
logging.error(f"Error displaying TOTP codes: {e}", exc_info=True)
print(colored(f"Error: Failed to display TOTP codes: {e}", "red"))

View File

@@ -126,7 +126,7 @@ class PasswordGenerator:
def _derive_password_entropy(self, index: int) -> bytes: def _derive_password_entropy(self, index: int) -> bytes:
"""Derive deterministic entropy for password generation.""" """Derive deterministic entropy for password generation."""
entropy = self.bip85.derive_entropy(index=index, bytes_len=64, app_no=32) entropy = self.bip85.derive_entropy(index=index, entropy_bytes=64, app_no=32)
logger.debug("Entropy derived for password generation.") logger.debug("Entropy derived for password generation.")
hkdf = HKDF( hkdf = HKDF(
@@ -433,7 +433,7 @@ class PasswordGenerator:
def derive_ssh_key(bip85: BIP85, idx: int) -> bytes: def derive_ssh_key(bip85: BIP85, idx: int) -> bytes:
"""Derive 32 bytes of entropy suitable for an SSH key.""" """Derive 32 bytes of entropy suitable for an SSH key."""
return bip85.derive_entropy(index=idx, bytes_len=32, app_no=32) return bip85.derive_entropy(index=idx, entropy_bytes=32, app_no=32)
def derive_ssh_key_pair(parent_seed: str, index: int) -> tuple[str, str]: def derive_ssh_key_pair(parent_seed: str, index: int) -> tuple[str, str]:
@@ -499,7 +499,7 @@ def derive_pgp_key(
import hashlib import hashlib
import datetime import datetime
entropy = bip85.derive_entropy(index=idx, bytes_len=32, app_no=32) entropy = bip85.derive_entropy(index=idx, entropy_bytes=32, app_no=32)
created = datetime.datetime(2000, 1, 1, tzinfo=datetime.timezone.utc) created = datetime.datetime(2000, 1, 1, tzinfo=datetime.timezone.utc)
if key_type.lower() == "rsa": if key_type.lower() == "rsa":

View File

@@ -0,0 +1,108 @@
from __future__ import annotations
import logging
from typing import Optional, TYPE_CHECKING
from termcolor import colored
import seedpass.core.manager as manager_module
from nostr.snapshot import MANIFEST_ID_PREFIX
from utils.password_prompt import prompt_existing_password
if TYPE_CHECKING: # pragma: no cover - typing only
from .manager import PasswordManager
from nostr.client import NostrClient
class ProfileService:
"""Profile-related operations for :class:`PasswordManager`."""
def __init__(self, manager: PasswordManager) -> None:
self.manager = manager
def handle_switch_fingerprint(self, *, password: Optional[str] = None) -> bool:
"""Handle switching to a different seed profile."""
pm = self.manager
try:
print(colored("\nAvailable Seed Profiles:", "cyan"))
fingerprints = pm.fingerprint_manager.list_fingerprints()
for idx, fp in enumerate(fingerprints, start=1):
display = (
pm.fingerprint_manager.display_name(fp)
if hasattr(pm.fingerprint_manager, "display_name")
else fp
)
print(colored(f"{idx}. {display}", "cyan"))
choice = input("Select a seed profile by number to switch: ").strip()
if not choice.isdigit() or not (1 <= int(choice) <= len(fingerprints)):
print(colored("Invalid selection. Returning to main menu.", "red"))
return False
selected_fingerprint = fingerprints[int(choice) - 1]
pm.fingerprint_manager.current_fingerprint = selected_fingerprint
pm.current_fingerprint = selected_fingerprint
if not getattr(pm, "manifest_id", None):
pm.manifest_id = f"{MANIFEST_ID_PREFIX}{selected_fingerprint}"
pm.fingerprint_dir = pm.fingerprint_manager.get_current_fingerprint_dir()
if not pm.fingerprint_dir:
print(
colored(
f"Error: Seed profile directory for {selected_fingerprint} not found.",
"red",
)
)
return False
if password is None:
password = prompt_existing_password(
"Enter the master password for the selected seed profile: "
)
if not pm.setup_encryption_manager(
pm.fingerprint_dir, password, exit_on_fail=False
):
return False
pm.initialize_bip85()
pm.initialize_managers()
pm.start_background_sync()
print(colored(f"Switched to seed profile {selected_fingerprint}.", "green"))
try:
pm.nostr_client = manager_module.NostrClient(
encryption_manager=pm.encryption_manager,
fingerprint=pm.current_fingerprint,
config_manager=getattr(pm, "config_manager", None),
parent_seed=getattr(pm, "parent_seed", None),
)
if getattr(pm, "manifest_id", None) and hasattr(
pm.nostr_client, "_state_lock"
):
from nostr.backup_models import Manifest
with pm.nostr_client._state_lock:
pm.nostr_client.current_manifest_id = pm.manifest_id
pm.nostr_client.current_manifest = Manifest(
ver=1,
algo="gzip",
chunks=[],
delta_since=pm.delta_since or None,
)
logging.info(
f"NostrClient re-initialized with seed profile {pm.current_fingerprint}."
)
except Exception as e:
logging.error(f"Failed to re-initialize NostrClient: {e}")
print(
colored(f"Error: Failed to re-initialize NostrClient: {e}", "red")
)
return False
return True
except Exception as e: # pragma: no cover - defensive
logging.error(f"Error during seed profile switching: {e}", exc_info=True)
print(colored(f"Error: Failed to switch seed profiles: {e}", "red"))
return False

4
src/seedpass/errors.py Normal file
View File

@@ -0,0 +1,4 @@
class VaultLockedError(Exception):
"""Raised when an operation requires an unlocked vault."""
pass

View File

@@ -393,7 +393,7 @@ class TotpViewerWindow(toga.Window):
def refresh_codes(self) -> None: def refresh_codes(self) -> None:
self.table.data = [] self.table.data = []
for idx, label, *_rest in self.entries.list_entries( for idx, label, *_rest in self.entries.list_entries(
filter_kind=EntryType.TOTP.value filter_kinds=[EntryType.TOTP.value]
): ):
entry = self.entries.retrieve_entry(idx) entry = self.entries.retrieve_entry(idx)
code = self.entries.get_totp_code(idx) code = self.entries.get_totp_code(idx)

View File

@@ -501,8 +501,10 @@ async def test_generate_password_no_special_chars(client):
return b"\x00" * 32 return b"\x00" * 32
class DummyBIP85: class DummyBIP85:
def derive_entropy(self, index: int, bytes_len: int, app_no: int = 32) -> bytes: def derive_entropy(
return bytes(range(bytes_len)) self, index: int, entropy_bytes: int, app_no: int = 32
) -> bytes:
return bytes(range(entropy_bytes))
api.app.state.pm.password_generator = PasswordGenerator( api.app.state.pm.password_generator = PasswordGenerator(
DummyEnc(), "seed", DummyBIP85() DummyEnc(), "seed", DummyBIP85()
@@ -529,8 +531,10 @@ async def test_generate_password_allowed_chars(client):
return b"\x00" * 32 return b"\x00" * 32
class DummyBIP85: class DummyBIP85:
def derive_entropy(self, index: int, bytes_len: int, app_no: int = 32) -> bytes: def derive_entropy(
return bytes((index + i) % 256 for i in range(bytes_len)) self, index: int, entropy_bytes: int, app_no: int = 32
) -> bytes:
return bytes((index + i) % 256 for i in range(entropy_bytes))
api.app.state.pm.password_generator = PasswordGenerator( api.app.state.pm.password_generator = PasswordGenerator(
DummyEnc(), "seed", DummyBIP85() DummyEnc(), "seed", DummyBIP85()

View File

@@ -0,0 +1,52 @@
from local_bip85.bip85 import BIP85
class DummyChild:
def PrivateKey(self):
return self
def Raw(self):
return self
def ToBytes(self):
return b"\x00" * 32
class DummyCtx:
def __init__(self):
self.last_path = None
def DerivePath(self, path: str):
self.last_path = path
return DummyChild()
def test_derivation_paths_for_entropy_lengths():
bip85 = BIP85(b"\x00" * 64)
ctx = DummyCtx()
bip85.bip32_ctx = ctx
vectors = [
(16, 12),
(24, 18),
(32, 24),
]
for entropy_bytes, word_count in vectors:
bip85.derive_entropy(
index=0,
entropy_bytes=entropy_bytes,
app_no=39,
word_count=word_count,
)
assert ctx.last_path == f"m/83696968'/39'/0'/{word_count}'/0'"
def test_default_word_count_from_entropy_bytes():
bip85 = BIP85(b"\x00" * 64)
ctx = DummyCtx()
bip85.bip32_ctx = ctx
bip85.derive_entropy(index=5, entropy_bytes=20, app_no=39)
assert ctx.last_path == "m/83696968'/39'/0'/20'/5'"

View File

@@ -0,0 +1,21 @@
import sys
from pathlib import Path
sys.path.append(str(Path(__file__).resolve().parents[1]))
from bip_utils import Bip39SeedGenerator
from local_bip85.bip85 import BIP85
from helpers import TEST_SEED
MASTER_XPRV = "xprv9s21ZrQH143K2LBWUUQRFXhucrQqBpKdRRxNVq2zBqsx8HVqFk2uYo8kmbaLLHRdqtQpUm98uKfu3vca1LqdGhUtyoFnCNkfmXRyPXLjbKb"
def test_init_with_seed_bytes():
seed_bytes = Bip39SeedGenerator(TEST_SEED).Generate()
bip85 = BIP85(seed_bytes)
assert isinstance(bip85, BIP85)
def test_init_with_xprv():
bip85 = BIP85(MASTER_XPRV)
assert isinstance(bip85, BIP85)

View File

@@ -16,7 +16,7 @@ from seedpass.core.entry_types import EntryType
class DummyPM: class DummyPM:
def __init__(self): def __init__(self):
self.entry_manager = SimpleNamespace( self.entry_manager = SimpleNamespace(
list_entries=lambda sort_by="index", filter_kind=None, include_archived=False: [ list_entries=lambda sort_by="index", filter_kinds=None, include_archived=False: [
(1, "Label", "user", "url", False) (1, "Label", "user", "url", False)
], ],
search_entries=lambda q, kinds=None: [ search_entries=lambda q, kinds=None: [

View File

@@ -21,8 +21,8 @@ class DummyEnc:
class DummyBIP85: class DummyBIP85:
def derive_entropy(self, index: int, bytes_len: int, app_no: int = 32) -> bytes: def derive_entropy(self, index: int, entropy_bytes: int, app_no: int = 32) -> bytes:
return bytes((index + i) % 256 for i in range(bytes_len)) return bytes((index + i) % 256 for i in range(entropy_bytes))
def make_manager(tmp_path: Path) -> PasswordManager: def make_manager(tmp_path: Path) -> PasswordManager:

View File

@@ -30,8 +30,8 @@ class DummyEntries:
self.data = [(1, "Example", None, None, False)] self.data = [(1, "Example", None, None, False)]
self.code = "111111" self.code = "111111"
def list_entries(self, sort_by="index", filter_kind=None, include_archived=False): def list_entries(self, sort_by="index", filter_kinds=None, include_archived=False):
if filter_kind: if filter_kinds:
return [(idx, label, None, None, False) for idx, label, *_ in self.data] return [(idx, label, None, None, False) for idx, label, *_ in self.data]
return self.data return self.data

View File

@@ -9,7 +9,7 @@ from seedpass_gui.app import MainWindow
class DummyEntries: class DummyEntries:
def list_entries(self, sort_by="index", filter_kind=None, include_archived=False): def list_entries(self, sort_by="index", filter_kinds=None, include_archived=False):
return [] return []
def search_entries(self, q): def search_entries(self, q):

View File

@@ -37,10 +37,30 @@ def test_add_and_modify_key_value():
"tags": [], "tags": [],
} }
# Appears in listing
assert em.list_entries() == [(idx, "API entry", None, None, False)]
# Modify key and value
em.modify_entry(idx, key="api_key2", value="def456") em.modify_entry(idx, key="api_key2", value="def456")
updated = em.retrieve_entry(idx) updated = em.retrieve_entry(idx)
assert updated["key"] == "api_key2" assert updated["key"] == "api_key2"
assert updated["value"] == "def456" assert updated["value"] == "def456"
# Archive and ensure it disappears from the default listing
em.archive_entry(idx)
archived = em.retrieve_entry(idx)
assert archived["archived"] is True
assert em.list_entries() == []
assert em.list_entries(include_archived=True) == [
(idx, "API entry", None, None, True)
]
# Restore and ensure it reappears
em.restore_entry(idx)
restored = em.retrieve_entry(idx)
assert restored["archived"] is False
assert em.list_entries() == [(idx, "API entry", None, None, False)]
# Values are not searchable
results = em.search_entries("def456") results = em.search_entries("def456")
assert results == [] assert results == []

View File

@@ -0,0 +1,85 @@
from __future__ import annotations
from pathlib import Path
from tempfile import TemporaryDirectory
from types import SimpleNamespace
from typer.testing import CliRunner
from seedpass.cli import app as cli_app
from seedpass.cli import entry as entry_cli
from helpers import create_vault, TEST_SEED, TEST_PASSWORD
from seedpass.core.backup import BackupManager
from seedpass.core.config_manager import ConfigManager
from seedpass.core.entry_management import EntryManager
from seedpass.core.manager import PasswordManager, EncryptionMode
def _setup_manager(tmp_path: Path) -> tuple[PasswordManager, EntryManager]:
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)
pm = PasswordManager.__new__(PasswordManager)
pm.encryption_mode = EncryptionMode.SEED_ONLY
pm.encryption_manager = enc_mgr
pm.vault = vault
pm.entry_manager = entry_mgr
pm.backup_manager = backup_mgr
pm.parent_seed = TEST_SEED
pm.nostr_client = SimpleNamespace()
pm.fingerprint_dir = tmp_path
pm.secret_mode_enabled = False
return pm, entry_mgr
def _create_all_entries(em: EntryManager) -> None:
em.add_entry("pw", 8)
em.add_totp("totp", TEST_SEED)
em.add_ssh_key("ssh", TEST_SEED)
em.add_seed("seed", TEST_SEED, words_num=12)
em.add_nostr_key("nostr", TEST_SEED)
em.add_pgp_key("pgp", TEST_SEED)
em.add_key_value("kv", "k", "v")
em.add_managed_account("acct", TEST_SEED)
def test_cli_list_all_types(monkeypatch):
with TemporaryDirectory() as tmpdir:
tmp_path = Path(tmpdir)
pm, em = _setup_manager(tmp_path)
_create_all_entries(em)
def fake_get_entry_service(_ctx):
return SimpleNamespace(
list_entries=lambda sort_by, filter_kinds, include_archived: pm.entry_manager.list_entries(
sort_by=sort_by,
filter_kinds=filter_kinds,
include_archived=include_archived,
)
)
monkeypatch.setattr(entry_cli, "_get_entry_service", fake_get_entry_service)
runner = CliRunner()
result = runner.invoke(cli_app, ["entry", "list"])
assert result.exit_code == 0
out = result.stdout
for label in ["pw", "totp", "ssh", "seed", "nostr", "pgp", "kv", "acct"]:
assert label in out
def test_menu_list_all_types(monkeypatch, capsys):
with TemporaryDirectory() as tmpdir:
tmp_path = Path(tmpdir)
pm, em = _setup_manager(tmp_path)
_create_all_entries(em)
inputs = iter(["1", "", ""]) # choose All then exit
monkeypatch.setattr("builtins.input", lambda *_: next(inputs))
pm.handle_list_entries()
out = capsys.readouterr().out
for label in ["pw", "totp", "ssh", "seed", "nostr", "pgp", "kv", "acct"]:
assert label in out

View File

@@ -57,5 +57,5 @@ def test_filter_by_type():
em = setup_entry_manager(tmp_path) em = setup_entry_manager(tmp_path)
em.add_entry("site", 8, "user") em.add_entry("site", 8, "user")
em.add_totp("Example", TEST_SEED) em.add_totp("Example", TEST_SEED)
result = em.list_entries(filter_kind=EntryType.TOTP.value) result = em.list_entries(filter_kinds=[EntryType.TOTP.value])
assert result == [(1, "Example", None, None, False)] assert result == [(1, "Example", None, None, False)]

View File

@@ -41,6 +41,9 @@ def test_add_and_get_managed_account_seed():
assert fp assert fp
assert (tmp_path / "accounts" / fp).exists() assert (tmp_path / "accounts" / fp).exists()
# Appears in listing
assert mgr.list_entries() == [(idx, "acct", None, None, False)]
phrase_a = mgr.get_managed_account_seed(idx, TEST_SEED) phrase_a = mgr.get_managed_account_seed(idx, TEST_SEED)
phrase_b = mgr.get_managed_account_seed(idx, TEST_SEED) phrase_b = mgr.get_managed_account_seed(idx, TEST_SEED)
assert phrase_a == phrase_b assert phrase_a == phrase_b
@@ -51,6 +54,23 @@ def test_add_and_get_managed_account_seed():
assert phrase_a == expected assert phrase_a == expected
assert generate_fingerprint(phrase_a) == fp assert generate_fingerprint(phrase_a) == fp
# Archive and ensure it disappears from default listing
mgr.archive_entry(idx)
archived = mgr.retrieve_entry(idx)
assert archived["archived"] is True
assert mgr.list_entries() == []
assert mgr.list_entries(include_archived=True) == [
(idx, "acct", None, None, True)
]
# Restore and ensure deterministic derivation is unchanged
mgr.restore_entry(idx)
restored = mgr.retrieve_entry(idx)
assert restored["archived"] is False
assert mgr.list_entries() == [(idx, "acct", None, None, False)]
phrase_c = mgr.get_managed_account_seed(idx, TEST_SEED)
assert phrase_c == expected
def test_load_and_exit_managed_account(monkeypatch): def test_load_and_exit_managed_account(monkeypatch):
with TemporaryDirectory() as tmpdir: with TemporaryDirectory() as tmpdir:

View File

@@ -13,8 +13,8 @@ class DummyEnc:
class DummyBIP85: class DummyBIP85:
def derive_entropy(self, index: int, bytes_len: int, app_no: int = 32) -> bytes: def derive_entropy(self, index: int, entropy_bytes: int, app_no: int = 32) -> bytes:
return bytes((index + i) % 256 for i in range(bytes_len)) return bytes((index + i) % 256 for i in range(entropy_bytes))
def make_generator(policy=None): def make_generator(policy=None):

View File

@@ -8,8 +8,8 @@ class DummyEnc:
class DummyBIP85: class DummyBIP85:
def derive_entropy(self, index: int, bytes_len: int, app_no: int = 32) -> bytes: def derive_entropy(self, index: int, entropy_bytes: int, app_no: int = 32) -> bytes:
return bytes((index + i) % 256 for i in range(bytes_len)) return bytes((index + i) % 256 for i in range(entropy_bytes))
def make_generator(): def make_generator():

View File

@@ -14,8 +14,8 @@ class DummyEnc:
class DummyBIP85: class DummyBIP85:
def derive_entropy(self, index: int, bytes_len: int, app_no: int = 32) -> bytes: def derive_entropy(self, index: int, entropy_bytes: int, app_no: int = 32) -> bytes:
return bytes((index + i) % 256 for i in range(bytes_len)) return bytes((index + i) % 256 for i in range(entropy_bytes))
def make_generator(): def make_generator():

View File

@@ -15,8 +15,8 @@ class DummyEnc:
class DummyBIP85: class DummyBIP85:
def derive_entropy(self, index: int, bytes_len: int, app_no: int = 32) -> bytes: def derive_entropy(self, index: int, entropy_bytes: int, app_no: int = 32) -> bytes:
return bytes((index + i) % 256 for i in range(bytes_len)) return bytes((index + i) % 256 for i in range(entropy_bytes))
def make_generator(): def make_generator():

View File

@@ -12,8 +12,8 @@ class DummyEnc:
class DummyBIP85: class DummyBIP85:
def derive_entropy(self, index: int, bytes_len: int, app_no: int = 32) -> bytes: def derive_entropy(self, index: int, entropy_bytes: int, app_no: int = 32) -> bytes:
return bytes((index + i) % 256 for i in range(bytes_len)) return bytes((index + i) % 256 for i in range(entropy_bytes))
def make_generator(): def make_generator():

View File

@@ -15,8 +15,8 @@ class DummyEnc:
class DummyBIP85: class DummyBIP85:
def derive_entropy(self, index: int, bytes_len: int, app_no: int = 32) -> bytes: def derive_entropy(self, index: int, entropy_bytes: int, app_no: int = 32) -> bytes:
return bytes((index + i) % 256 for i in range(bytes_len)) return bytes((index + i) % 256 for i in range(entropy_bytes))
def make_generator(policy=None): def make_generator(policy=None):

View File

@@ -14,8 +14,8 @@ class DummyEnc:
class DummyBIP85: class DummyBIP85:
def derive_entropy(self, index: int, bytes_len: int, app_no: int = 32) -> bytes: def derive_entropy(self, index: int, entropy_bytes: int, app_no: int = 32) -> bytes:
return bytes((index + i) % 256 for i in range(bytes_len)) return bytes((index + i) % 256 for i in range(entropy_bytes))
def make_generator(policy=None): def make_generator(policy=None):

View File

@@ -0,0 +1,93 @@
import builtins
from types import SimpleNamespace
import pytest
import seedpass.core.manager as manager_module
from helpers import TEST_SEED
from utils import seed_prompt
def test_prompt_seed_words_confirmation_loop(monkeypatch):
phrase = TEST_SEED
words = phrase.split()
inputs = iter(words + [words[2]])
confirmations = iter(["y", "y", "n", "y"] + ["y"] * (len(words) - 3))
monkeypatch.setattr(seed_prompt, "masked_input", lambda *_: next(inputs))
monkeypatch.setattr(seed_prompt, "_apply_backoff", lambda *_a, **_k: None)
monkeypatch.setattr(seed_prompt, "clear_screen", lambda *_a, **_k: None)
monkeypatch.setattr(builtins, "input", lambda *_: next(confirmations))
result = seed_prompt.prompt_seed_words(len(words))
assert result == phrase
def test_prompt_seed_words_invalid_word(monkeypatch):
phrase = TEST_SEED
words = phrase.split()
inputs = iter(["invalid"] + words)
confirmations = iter(["y"] * len(words))
monkeypatch.setattr(seed_prompt, "masked_input", lambda *_: next(inputs))
monkeypatch.setattr(seed_prompt, "_apply_backoff", lambda *_a, **_k: None)
monkeypatch.setattr(seed_prompt, "clear_screen", lambda *_a, **_k: None)
monkeypatch.setattr(builtins, "input", lambda *_: next(confirmations))
result = seed_prompt.prompt_seed_words(len(words))
assert result == phrase
def test_add_new_fingerprint_words_flow_success(monkeypatch):
pm = manager_module.PasswordManager.__new__(manager_module.PasswordManager)
pm.fingerprint_manager = SimpleNamespace(current_fingerprint=None)
pm.initialize_managers = lambda: None
phrase = TEST_SEED
words = phrase.split()
word_iter = iter(words)
inputs = iter(["2"] + ["y"] * len(words))
monkeypatch.setattr(seed_prompt, "masked_input", lambda *_: next(word_iter))
monkeypatch.setattr(seed_prompt, "_apply_backoff", lambda *_a, **_k: None)
monkeypatch.setattr(seed_prompt, "clear_screen", lambda *_a, **_k: None)
monkeypatch.setattr(builtins, "input", lambda *_: next(inputs))
captured = {}
def finalize(self, seed, password=None):
captured["seed"] = seed
self.parent_seed = seed
return "fp"
monkeypatch.setattr(
manager_module.PasswordManager, "_finalize_existing_seed", finalize
)
result = pm.add_new_fingerprint()
assert result == "fp"
assert pm.fingerprint_manager.current_fingerprint == "fp"
assert captured["seed"] == phrase
assert pm.parent_seed == phrase
def test_add_new_fingerprint_words_flow_invalid_phrase(monkeypatch):
pm = manager_module.PasswordManager.__new__(manager_module.PasswordManager)
pm.fingerprint_manager = SimpleNamespace(current_fingerprint=None)
pm.initialize_managers = lambda: None
words = ["abandon"] * 12
word_iter = iter(words)
inputs = iter(["2"] + ["y"] * len(words))
monkeypatch.setattr(seed_prompt, "masked_input", lambda *_: next(word_iter))
monkeypatch.setattr(seed_prompt, "_apply_backoff", lambda *_a, **_k: None)
monkeypatch.setattr(seed_prompt, "clear_screen", lambda *_a, **_k: None)
monkeypatch.setattr(builtins, "input", lambda *_: next(inputs))
with pytest.raises(SystemExit):
pm.add_new_fingerprint()
assert pm.fingerprint_manager.current_fingerprint is None
assert not hasattr(pm, "parent_seed")

View File

@@ -0,0 +1,134 @@
from tempfile import TemporaryDirectory
from types import SimpleNamespace
from pathlib import Path
import pytest
from helpers import create_vault, TEST_SEED, TEST_PASSWORD, dummy_nostr_client
from seedpass.core.entry_management import EntryManager
from seedpass.core.backup import BackupManager
from seedpass.core.manager import PasswordManager, EncryptionMode
from seedpass.core.config_manager import ConfigManager
from seedpass.core.entry_service import EntryService
from seedpass.core.profile_service import ProfileService
from constants import DEFAULT_PASSWORD_LENGTH
class FakePasswordGenerator:
def generate_password(self, length: int, index: int) -> str:
return f"pw-{index}-{length}"
def _setup_pm(tmp_path: Path, client) -> PasswordManager:
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)
pm = PasswordManager.__new__(PasswordManager)
pm.encryption_mode = EncryptionMode.SEED_ONLY
pm.encryption_manager = enc_mgr
pm.vault = vault
pm.entry_manager = entry_mgr
pm.backup_manager = backup_mgr
pm.password_generator = FakePasswordGenerator()
pm.parent_seed = TEST_SEED
pm.nostr_client = client
pm.fingerprint_dir = tmp_path
pm.secret_mode_enabled = False
pm.is_dirty = False
return pm
def test_entry_service_add_password(monkeypatch, dummy_nostr_client, capsys):
client, _relay = dummy_nostr_client
with TemporaryDirectory() as tmpdir:
pm = _setup_pm(Path(tmpdir), client)
service = EntryService(pm)
inputs = iter(
[
"a",
"Example",
"",
"",
"",
"",
"n",
"",
"",
"",
"",
"",
"",
"",
"",
"",
]
)
monkeypatch.setattr("builtins.input", lambda *a, **k: next(inputs))
monkeypatch.setattr("seedpass.core.entry_service.pause", lambda *a, **k: None)
monkeypatch.setattr(pm, "start_background_vault_sync", lambda *a, **k: None)
service.handle_add_password()
out = capsys.readouterr().out
entries = pm.entry_manager.list_entries(verbose=False)
assert entries == [(0, "Example", "", "", False)]
assert f"pw-0-{DEFAULT_PASSWORD_LENGTH}" in out
def test_menu_handler_list_entries(monkeypatch, capsys):
with TemporaryDirectory() as tmpdir:
pm = _setup_pm(Path(tmpdir), SimpleNamespace())
pm.entry_manager.add_totp("Example", TEST_SEED)
pm.entry_manager.add_entry("example.com", 12)
pm.entry_manager.add_key_value("API entry", "api", "abc123")
pm.entry_manager.add_managed_account("acct", TEST_SEED)
inputs = iter(["1", ""]) # list all then exit
monkeypatch.setattr("builtins.input", lambda *_: next(inputs))
pm.menu_handler.handle_list_entries()
out = capsys.readouterr().out
assert "Example" in out
assert "example.com" in out
assert "API" in out
assert "acct" in out
def test_profile_service_switch(monkeypatch):
class DummyFingerprintManager:
def __init__(self):
self.fingerprints = ["fp1", "fp2"]
self.current_fingerprint = "fp1"
def list_fingerprints(self):
return self.fingerprints
def display_name(self, fp):
return fp
def get_current_fingerprint_dir(self):
return Path(".")
pm = PasswordManager.__new__(PasswordManager)
pm.fingerprint_manager = DummyFingerprintManager()
pm.current_fingerprint = "fp1"
pm.setup_encryption_manager = lambda *a, **k: True
pm.initialize_bip85 = lambda *a, **k: None
pm.initialize_managers = lambda *a, **k: None
pm.start_background_sync = lambda *a, **k: None
pm.nostr_client = SimpleNamespace()
pm.manifest_id = None
pm.delta_since = None
pm.encryption_manager = SimpleNamespace()
pm.parent_seed = TEST_SEED
service = ProfileService(pm)
monkeypatch.setattr("builtins.input", lambda *_: "2")
monkeypatch.setattr(
"seedpass.core.profile_service.prompt_existing_password", lambda *_: "pw"
)
monkeypatch.setattr(
"seedpass.core.manager.NostrClient", lambda *a, **k: SimpleNamespace()
)
assert service.handle_switch_fingerprint() is True
assert pm.current_fingerprint == "fp2"

View File

@@ -18,8 +18,8 @@ runner = CliRunner()
def test_entry_list(monkeypatch): def test_entry_list(monkeypatch):
called = {} called = {}
def list_entries(sort_by="index", filter_kind=None, include_archived=False): def list_entries(sort_by="index", filter_kinds=None, include_archived=False):
called["args"] = (sort_by, filter_kind, include_archived) called["args"] = (sort_by, filter_kinds, include_archived)
return [(0, "Site", "user", "", False)] return [(0, "Site", "user", "", False)]
pm = SimpleNamespace( pm = SimpleNamespace(

View File

@@ -0,0 +1,52 @@
import pytest
from types import SimpleNamespace
from seedpass.core.manager import PasswordManager
from seedpass.errors import VaultLockedError
class DummyEntryManager:
def __init__(self):
self.cleared = False
def clear_cache(self):
self.cleared = True
def test_lock_vault_sets_flag_and_keeps_objects():
pm = PasswordManager.__new__(PasswordManager)
em = DummyEntryManager()
pm.entry_manager = em
pm.is_locked = False
pm.locked = False
pm.lock_vault()
assert pm.is_locked
assert pm.locked
assert pm.entry_manager is em
assert em.cleared
def test_entry_service_requires_unlocked():
pm = PasswordManager.__new__(PasswordManager)
service = SimpleNamespace()
pm._entry_service = service
pm.is_locked = True
with pytest.raises(VaultLockedError):
_ = pm.entry_service
pm.is_locked = False
assert pm.entry_service is service
def test_unlock_vault_clears_locked_flag(tmp_path):
pm = PasswordManager.__new__(PasswordManager)
pm.fingerprint_dir = tmp_path
pm.parent_seed = "seed"
pm.setup_encryption_manager = lambda *a, **k: None
pm.initialize_bip85 = lambda: None
pm.initialize_managers = lambda: None
pm.update_activity = lambda: None
pm.is_locked = True
pm.locked = True
pm.unlock_vault("pw")
assert not pm.is_locked
assert not pm.locked

View File

@@ -23,7 +23,9 @@ import hmac
import time import time
from enum import Enum from enum import Enum
from typing import Optional, Union from typing import Optional, Union
from bip_utils import Bip39SeedGenerator from bip_utils import Bip39SeedGenerator
from local_bip85 import BIP85
from cryptography.hazmat.primitives.kdf.hkdf import HKDF from cryptography.hazmat.primitives.kdf.hkdf import HKDF
from cryptography.hazmat.primitives import hashes from cryptography.hazmat.primitives import hashes
@@ -213,8 +215,6 @@ def derive_index_key(seed: str) -> bytes:
def derive_totp_secret(seed: str, index: int) -> str: def derive_totp_secret(seed: str, index: int) -> str:
"""Derive a base32-encoded TOTP secret from a BIP39 seed.""" """Derive a base32-encoded TOTP secret from a BIP39 seed."""
try: try:
from local_bip85 import BIP85
# Initialize BIP85 from the BIP39 seed bytes # Initialize BIP85 from the BIP39 seed bytes
seed_bytes = Bip39SeedGenerator(seed).Generate() seed_bytes = Bip39SeedGenerator(seed).Generate()
bip85 = BIP85(seed_bytes) bip85 = BIP85(seed_bytes)

View File

@@ -9,10 +9,10 @@ class SlowBIP85:
def __init__(self): def __init__(self):
self.calls = 0 self.calls = 0
def derive_entropy(self, index: int, bytes_len: int, app_no: int = 39) -> bytes: def derive_entropy(self, index: int, entropy_bytes: int, app_no: int = 39) -> bytes:
self.calls += 1 self.calls += 1
time.sleep(0.01) time.sleep(0.01)
return b"\x00" * bytes_len return b"\x00" * entropy_bytes
def _setup_manager(bip85: SlowBIP85) -> PasswordManager: def _setup_manager(bip85: SlowBIP85) -> PasswordManager:
@@ -21,10 +21,12 @@ def _setup_manager(bip85: SlowBIP85) -> PasswordManager:
pm.bip85 = bip85 pm.bip85 = bip85
orig = bip85.derive_entropy orig = bip85.derive_entropy
def cached(index: int, bytes_len: int, app_no: int = 39) -> bytes: def cached(index: int, entropy_bytes: int, app_no: int = 39) -> bytes:
key = (app_no, index) key = (app_no, index)
if key not in pm._bip85_cache: if key not in pm._bip85_cache:
pm._bip85_cache[key] = orig(index=index, bytes_len=bytes_len, app_no=app_no) pm._bip85_cache[key] = orig(
index=index, entropy_bytes=entropy_bytes, app_no=app_no
)
return pm._bip85_cache[key] return pm._bip85_cache[key]
bip85.derive_entropy = cached bip85.derive_entropy = cached
@@ -44,7 +46,7 @@ def test_bip85_cache_benchmark():
for _ in range(3): for _ in range(3):
pm.get_bip85_entropy(32, 1) pm.get_bip85_entropy(32, 1)
cached_time = time.perf_counter() - start cached_time = time.perf_counter() - start
# Ensure caching avoids redundant derive calls without relying on
assert cached_time < uncached_time # potentially flaky timing comparisons across platforms.
assert slow_uncached.calls == 3 assert slow_uncached.calls == 3
assert slow_cached.calls == 1 assert slow_cached.calls == 1