20 Commits

Author SHA1 Message Date
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
29 changed files with 989 additions and 660 deletions

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"
@@ -18,10 +20,10 @@ BRANCH="main" # Default branch
INSTALL_GUI=false INSTALL_GUI=false
# --- 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,24 +32,31 @@ 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>] [--with-gui] [-h | --help]"
@@ -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

@@ -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")

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

@@ -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,196 @@
from __future__ import annotations
import logging
import sys
from typing import TYPE_CHECKING
from termcolor import colored
from .entry_types import EntryType
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"))
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 = pm.entry_manager.get_entry_summaries(
filter_kind, 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_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
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

@@ -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

@@ -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

@@ -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,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

@@ -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