Merge branch 'beta' into main (safe conflict resolution)

This commit is contained in:
thePR0M3TH3AN committed 2026-02-23 16:38:18 -05:00
commit 37d0dffdc3
462 files changed
+68862 -6529

No files matched your search

+2 -6
View File
@@ -34,13 +34,9 @@ def initialize_app() -> None:
"""Ensure the application directory exists."""
try:
APP_DIR.mkdir(exist_ok=True, parents=True)
if logger.isEnabledFor(logging.DEBUG):
logger.info(f"Application directory created at {APP_DIR}")
logger.debug("Application directory created at %s", APP_DIR)
except Exception as exc:
if logger.isEnabledFor(logging.DEBUG):
logger.error(
f"Failed to create application directory: {exc}", exc_info=True
)
logger.error("Failed to create application directory: %s", exc, exc_info=True)
# -----------------------------------
+5 -7
View File
@@ -1,17 +1,15 @@
# bip85/__init__.py
import logging
import traceback
logger = logging.getLogger(__name__)
try:
from .bip85 import BIP85
if logger.isEnabledFor(logging.DEBUG):
logger.info("BIP85 module imported successfully.")
except Exception as e:
if logger.isEnabledFor(logging.DEBUG):
logger.error(f"Failed to import BIP85 module: {e}", exc_info=True)
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
__all__ = ["BIP85"]
+45 -25
View File
@@ -18,7 +18,8 @@ import hashlib
import hmac
import logging
import os
import traceback
from typing import Union
from colorama import Fore
from bip_utils import Bip32Slip10Secp256k1, Bip39MnemonicGenerator, Bip39Languages
@@ -38,13 +39,19 @@ class Bip85Error(Exception):
class BIP85:
def __init__(self, seed_bytes: bytes | str):
"""Initialize from BIP39 seed bytes or BIP32 xprv string."""
def __init__(self, seed_or_xprv: Union[bytes, str]):
"""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:
if isinstance(seed_bytes, (bytes, bytearray)):
self.bip32_ctx = Bip32Slip10Secp256k1.FromSeed(seed_bytes)
if isinstance(seed_or_xprv, (bytes, bytearray)):
self.bip32_ctx = Bip32Slip10Secp256k1.FromSeed(seed_or_xprv)
else:
self.bip32_ctx = Bip32Slip10Secp256k1.FromExtendedKey(seed_bytes)
self.bip32_ctx = Bip32Slip10Secp256k1.FromExtendedKey(seed_or_xprv)
logging.debug("BIP32 context initialized successfully.")
except Exception as e:
logging.error(f"Error initializing BIP32 context: {e}", exc_info=True)
@@ -52,26 +59,36 @@ class BIP85:
raise Bip85Error(f"Error initializing BIP32 context: {e}")
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:
"""
Derives entropy using BIP-85 HMAC-SHA512 method.
"""Derive entropy using the BIP-85 HMAC-SHA512 method.
Parameters:
index (int): Index for the child entropy.
bytes_len (int): Number of bytes to derive for the entropy.
app_no (int): Application number (default 39 for BIP39)
entropy_bytes (int): Number of bytes of entropy to derive.
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:
bytes: Derived entropy.
bytes: Derived entropy of length ``entropy_bytes``.
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 words_len is None:
words_len = bytes_len
path = f"m/83696968'/{app_no}'/0'/{words_len}'/{index}'"
if word_count is None:
word_count = entropy_bytes
path = f"m/83696968'/{app_no}'/0'/{word_count}'/{index}'"
elif app_no == 32:
path = f"m/83696968'/{app_no}'/{index}'"
else:
# Handle other app_no if necessary
path = f"m/83696968'/{app_no}'/{index}'"
@@ -85,17 +102,17 @@ class BIP85:
hmac_result = hmac.new(hmac_key, k, hashlib.sha512).digest()
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(
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(
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(
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()}")
@@ -106,14 +123,17 @@ class BIP85:
raise Bip85Error(f"Error deriving entropy: {e}")
def derive_mnemonic(self, index: int, words_num: int) -> str:
bytes_len = {12: 16, 18: 24, 24: 32}.get(words_num)
if not bytes_len:
entropy_bytes = {12: 16, 18: 24, 24: 32}.get(words_num)
if not entropy_bytes:
logging.error(f"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}")
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:
mnemonic = Bip39MnemonicGenerator(Bip39Languages.ENGLISH).FromEntropy(
@@ -129,7 +149,7 @@ class BIP85:
def derive_symmetric_key(self, index: int = 0, app_no: int = 2) -> bytes:
"""Derive 32 bytes of entropy for symmetric key usage."""
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()}")
return key
except Exception as e:
+293 -135
View File
@@ -11,20 +11,25 @@ import os
import logging
import signal
import time
import queue
import argparse
import asyncio
import gzip
import tomli
from tomli import TOMLDecodeError
from colorama import init as colorama_init
from termcolor import colored
from utils.color_scheme import color_text
import traceback
import importlib
from seedpass.core.manager import PasswordManager
from seedpass.core.manager import PasswordManager, restore_backup_index
from nostr.client import NostrClient
from seedpass.core.entry_types import EntryType
from seedpass.core.config_manager import ConfigManager
from constants import INACTIVITY_TIMEOUT, initialize_app
from utils.password_prompt import PasswordPromptError
from utils.password_prompt import (
PasswordPromptError,
prompt_existing_password,
prompt_new_password,
)
from utils import (
timed_input,
copy_to_clipboard,
@@ -32,11 +37,36 @@ from utils import (
pause,
clear_header_with_notification,
)
import queue
from utils.clipboard import ClipboardUnavailableError
from utils.atomic_write import atomic_write
from utils.logging_utils import (
ConsolePauseFilter,
ChecksumWarningFilter,
pause_logging_for_ui,
)
from local_bip85.bip85 import Bip85Error
colorama_init()
OPTIONAL_DEPENDENCIES = {
"pyperclip": "clipboard support for secret mode",
"qrcode": "QR code generation for TOTP setup",
"toga": "desktop GUI features",
}
def _warn_missing_optional_dependencies() -> None:
"""Log warnings for any optional packages that are not installed."""
for module, feature in OPTIONAL_DEPENDENCIES.items():
try:
importlib.import_module(module)
except ModuleNotFoundError:
logging.debug(
"Optional dependency '%s' is not installed; %s will be unavailable.",
module,
feature,
)
def load_global_config() -> dict:
"""Load configuration from ~/.seedpass/config.toml if present."""
@@ -46,48 +76,44 @@ def load_global_config() -> dict:
try:
with open(config_path, "rb") as f:
return tomli.load(f)
except Exception as exc:
except (OSError, TOMLDecodeError) as exc:
logging.warning(f"Failed to read {config_path}: {exc}")
return {}
def configure_logging():
logger = logging.getLogger()
logger.setLevel(logging.DEBUG) # Keep this as DEBUG to capture all logs
def configure_logging() -> None:
"""Configure application-wide logging handlers."""
# Remove all handlers associated with the root logger object
for handler in logger.handlers[:]:
logger.removeHandler(handler)
# Ensure the 'logs' directory exists
log_directory = Path("logs")
if not log_directory.exists():
log_directory.mkdir(parents=True, exist_ok=True)
log_directory.mkdir(parents=True, exist_ok=True)
# Create handlers
c_handler = logging.StreamHandler(sys.stdout)
f_handler = logging.FileHandler(log_directory / "main.log")
console_handler = logging.StreamHandler(sys.stderr)
console_handler.setLevel(logging.WARNING)
console_handler.addFilter(ConsolePauseFilter())
console_handler.addFilter(ChecksumWarningFilter())
# Set levels: only errors and critical messages will be shown in the console
c_handler.setLevel(logging.ERROR)
f_handler.setLevel(logging.DEBUG)
file_handler = logging.FileHandler(log_directory / "main.log")
file_handler.setLevel(logging.DEBUG)
# Create formatters and add them to handlers
formatter = logging.Formatter(
"%(asctime)s [%(levelname)s] %(message)s [%(filename)s:%(lineno)d]"
"%(asctime)s [%(levelname)s] %(message)s [%(filename)s:%(lineno)d]",
)
c_handler.setFormatter(formatter)
f_handler.setFormatter(formatter)
console_handler.setFormatter(formatter)
file_handler.setFormatter(formatter)
# Add handlers to the logger
logger.addHandler(c_handler)
logger.addHandler(f_handler)
root_logger = logging.getLogger()
root_logger.setLevel(logging.DEBUG)
root_logger.handlers.clear()
root_logger.addHandler(console_handler)
root_logger.addHandler(file_handler)
# Set logging level for third-party libraries to WARNING to suppress their debug logs
logging.getLogger("monstr").setLevel(logging.WARNING)
logging.getLogger("nostr").setLevel(logging.WARNING)
logging.captureWarnings(True)
logging.getLogger("monstr").setLevel(logging.ERROR)
logging.getLogger("nostr").setLevel(logging.ERROR)
@pause_logging_for_ui
def confirm_action(prompt: str) -> bool:
"""
Prompts the user for confirmation.
@@ -136,6 +162,7 @@ def get_notification_text(pm: PasswordManager) -> str:
return color_text(getattr(note, "message", ""), category)
@pause_logging_for_ui
def handle_switch_fingerprint(password_manager: PasswordManager):
"""
Handles switching the active fingerprint.
@@ -164,6 +191,13 @@ def handle_switch_fingerprint(password_manager: PasswordManager):
return
selected_fingerprint = fingerprints[int(choice) - 1]
if selected_fingerprint == password_manager.current_fingerprint:
print(
colored(
f"Seed profile {selected_fingerprint} is already active.", "yellow"
)
)
return
if password_manager.select_fingerprint(selected_fingerprint):
print(colored(f"Switched to seed profile {selected_fingerprint}.", "green"))
else:
@@ -187,11 +221,7 @@ def handle_add_new_fingerprint(password_manager: PasswordManager):
def handle_remove_fingerprint(password_manager: PasswordManager):
"""
Handles removing an existing seed profile.
:param password_manager: An instance of PasswordManager.
"""
"""Handle removing an existing seed profile."""
try:
fingerprints = password_manager.fingerprint_manager.list_fingerprints()
if not fingerprints:
@@ -210,12 +240,24 @@ def handle_remove_fingerprint(password_manager: PasswordManager):
selected_fingerprint = fingerprints[int(choice) - 1]
confirm = confirm_action(
f"Are you sure you want to remove seed profile {selected_fingerprint}? This will delete all associated data. (Y/N): "
f"Are you sure you want to remove seed profile {selected_fingerprint}? This will delete all associated data. (Y/N):"
)
if confirm:
def _cleanup_and_exit() -> None:
password_manager.current_fingerprint = None
password_manager.is_dirty = False
getattr(password_manager, "cleanup", lambda: None)()
print(colored("All seed profiles removed. Exiting.", "yellow"))
sys.exit(0)
if password_manager.fingerprint_manager.remove_fingerprint(
selected_fingerprint
selected_fingerprint, _cleanup_and_exit
):
password_manager.current_fingerprint = (
password_manager.fingerprint_manager.current_fingerprint
)
password_manager.is_dirty = False
print(
colored(
f"Seed profile {selected_fingerprint} removed successfully.",
@@ -274,10 +316,9 @@ def handle_display_npub(password_manager: PasswordManager):
def _display_live_stats(
password_manager: PasswordManager, interval: float = 1.0
) -> None:
"""Continuously refresh stats until the user presses Enter.
"""Display stats and wait for the user to continue.
Each refresh also triggers a background sync so the latest stats are
displayed if newer data exists on Nostr.
A background sync runs before rendering so the latest stats are shown.
"""
stats_mgr = getattr(password_manager, "stats_manager", None)
@@ -304,28 +345,17 @@ def _display_live_stats(
stats_mgr.reset()
return
while True:
if callable(sync_fn):
try:
sync_fn()
except Exception: # pragma: no cover - sync best effort
logging.debug("Background sync failed during stats display")
clear_screen()
display_fn()
note = get_notification_text(password_manager)
if note:
print(note)
print(colored("Press Enter to continue.", "cyan"))
sys.stdout.flush()
try:
user_input = timed_input("", interval)
if user_input.strip() == "" or user_input.strip().lower() == "b":
break
except TimeoutError:
pass
except KeyboardInterrupt:
print()
break
clear_screen()
display_fn()
note = get_notification_text(password_manager)
if note:
print(note)
print(colored("Press Enter to continue.", "cyan"))
sys.stdout.flush()
try:
timed_input("", None)
except KeyboardInterrupt:
print()
if stats_mgr is not None:
stats_mgr.reset()
@@ -403,34 +433,36 @@ def handle_post_to_nostr(
def handle_retrieve_from_nostr(password_manager: PasswordManager):
"""
Handles the action of retrieving the encrypted password index from Nostr.
"""
"""Retrieve the encrypted password index from Nostr."""
try:
password_manager.nostr_client.fingerprint = password_manager.current_fingerprint
result = asyncio.run(password_manager.nostr_client.fetch_latest_snapshot())
if result:
manifest, chunks = result
encrypted = gzip.decompress(b"".join(chunks))
if manifest.delta_since:
version = int(manifest.delta_since)
deltas = asyncio.run(
password_manager.nostr_client.fetch_deltas_since(version)
)
if deltas:
encrypted = deltas[-1]
password_manager.encryption_manager.decrypt_and_save_index_from_nostr(
encrypted
)
print(colored("Encrypted index retrieved and saved successfully.", "green"))
logging.info("Encrypted index retrieved and saved successfully from Nostr.")
else:
password_manager.sync_index_from_nostr()
if password_manager.nostr_client.last_error:
msg = (
f"No Nostr events found for fingerprint"
f" {password_manager.current_fingerprint}."
if "Snapshot not found" in password_manager.nostr_client.last_error
else password_manager.nostr_client.last_error
)
print(colored(msg, "red"))
logging.error(msg)
else:
try:
legacy_pub = (
password_manager.nostr_client.key_manager.generate_legacy_nostr_keys().public_key_hex()
)
if password_manager.nostr_client.keys.public_key_hex() == legacy_pub:
note = "Restored index from legacy Nostr backup."
print(colored(note, "yellow"))
logging.info(note)
except Exception:
pass
print(
colored(
"Encrypted index retrieved and saved successfully.",
"green",
)
)
logging.info("Encrypted index retrieved and saved successfully from Nostr.")
except Exception as e:
logging.error(f"Failed to retrieve from Nostr: {e}", exc_info=True)
print(colored(f"Error: Failed to retrieve from Nostr: {e}", "red"))
@@ -601,33 +633,49 @@ def handle_set_inactivity_timeout(password_manager: PasswordManager) -> None:
def handle_set_kdf_iterations(password_manager: PasswordManager) -> None:
"""Change the PBKDF2 iteration count."""
"""Interactive slider for PBKDF2 iteration strength with benchmarking."""
import hashlib
import time
cfg_mgr = password_manager.config_manager
if cfg_mgr is None:
print(colored("Configuration manager unavailable.", "red"))
return
levels = [
("1", "Very Fast", 10_000),
("2", "Fast", 50_000),
("3", "Balanced", 100_000),
("4", "Slow", 200_000),
("5", "Paranoid", 500_000),
]
try:
current = cfg_mgr.get_kdf_iterations()
print(colored(f"Current iterations: {current}", "cyan"))
except Exception as e:
logging.error(f"Error loading iterations: {e}")
print(colored(f"Error: {e}", "red"))
return
value = input("Enter new iteration count: ").strip()
if not value:
print(colored("No iteration count entered.", "yellow"))
print(colored(f"Current iterations: {current}", "cyan"))
for key, label, iters in levels:
marker = "*" if iters == current else " "
print(colored(f"{key}. {label} ({iters}) {marker}", "menu"))
print(colored("b. Benchmark current setting", "menu"))
choice = input("Select strength or 'b' to benchmark: ").strip().lower()
if not choice:
print(colored("No change made.", "yellow"))
return
if choice == "b":
start = time.perf_counter()
hashlib.pbkdf2_hmac("sha256", b"bench", b"salt", current)
elapsed = time.perf_counter() - start
print(colored(f"{current} iterations took {elapsed:.2f}s", "green"))
return
selected = {k: v for k, _, v in levels}.get(choice)
if not selected:
print(colored("Invalid choice.", "red"))
return
try:
iterations = int(value)
if iterations <= 0:
print(colored("Iterations must be positive.", "red"))
return
except ValueError:
print(colored("Invalid number.", "red"))
return
try:
cfg_mgr.set_kdf_iterations(iterations)
print(colored("KDF iteration count updated.", "green"))
cfg_mgr.set_kdf_iterations(selected)
print(colored(f"KDF iteration count set to {selected}.", "green"))
except Exception as e:
logging.error(f"Error saving iterations: {e}")
print(colored(f"Error: {e}", "red"))
@@ -666,8 +714,7 @@ def handle_set_additional_backup_location(pm: PasswordManager) -> None:
path = Path(value).expanduser()
path.mkdir(parents=True, exist_ok=True)
test_file = path / ".seedpass_write_test"
with open(test_file, "w") as f:
f.write("test")
atomic_write(test_file, lambda f: f.write("test"))
test_file.unlink()
except Exception as e:
print(colored(f"Path not writable: {e}", "red"))
@@ -706,8 +753,18 @@ def handle_toggle_secret_mode(pm: PasswordManager) -> None:
"""Toggle secret mode and adjust clipboard delay."""
cfg = pm.config_manager
if cfg is None:
print(colored("Configuration manager unavailable.", "red"))
return
vault = getattr(pm, "vault", None)
fingerprint_dir = getattr(pm, "fingerprint_dir", None)
if vault is not None and fingerprint_dir is not None:
try:
cfg = pm.config_manager = ConfigManager(vault, fingerprint_dir)
except Exception as exc:
logging.error(f"Failed to initialize ConfigManager: {exc}")
print(colored("Configuration manager unavailable.", "red"))
return
else:
print(colored("Configuration manager unavailable.", "red"))
return
try:
enabled = cfg.get_secret_mode_enabled()
delay = cfg.get_clipboard_clear_delay()
@@ -747,8 +804,18 @@ def handle_toggle_quick_unlock(pm: PasswordManager) -> None:
"""Enable or disable Quick Unlock."""
cfg = pm.config_manager
if cfg is None:
print(colored("Configuration manager unavailable.", "red"))
return
vault = getattr(pm, "vault", None)
fingerprint_dir = getattr(pm, "fingerprint_dir", None)
if vault is not None and fingerprint_dir is not None:
try:
cfg = pm.config_manager = ConfigManager(vault, fingerprint_dir)
except Exception as exc:
logging.error(f"Failed to initialize ConfigManager: {exc}")
print(colored("Configuration manager unavailable.", "red"))
return
else:
print(colored("Configuration manager unavailable.", "red"))
return
try:
enabled = cfg.get_quick_unlock()
except Exception as exc:
@@ -774,8 +841,18 @@ def handle_toggle_offline_mode(pm: PasswordManager) -> None:
"""Enable or disable offline mode."""
cfg = pm.config_manager
if cfg is None:
print(colored("Configuration manager unavailable.", "red"))
return
vault = getattr(pm, "vault", None)
fingerprint_dir = getattr(pm, "fingerprint_dir", None)
if vault is not None and fingerprint_dir is not None:
try:
cfg = pm.config_manager = ConfigManager(vault, fingerprint_dir)
except Exception as exc:
logging.error(f"Failed to initialize ConfigManager: {exc}")
print(colored("Configuration manager unavailable.", "red"))
return
else:
print(colored("Configuration manager unavailable.", "red"))
return
try:
enabled = cfg.get_offline_mode()
except Exception as exc:
@@ -916,12 +993,12 @@ def handle_settings(password_manager: PasswordManager) -> None:
print(color_text("8. Import database", "menu"))
print(color_text("9. Export 2FA codes", "menu"))
print(color_text("10. Set additional backup location", "menu"))
print(color_text("11. Set KDF iterations", "menu"))
print(color_text("11. KDF strength & benchmark", "menu"))
print(color_text("12. Set inactivity timeout", "menu"))
print(color_text("13. Lock Vault", "menu"))
print(color_text("14. Stats", "menu"))
print(color_text("15. Toggle Secret Mode", "menu"))
print(color_text("16. Toggle Offline Mode", "menu"))
print(color_text("16. Toggle Offline Mode (default ON)", "menu"))
print(color_text("17. Toggle Quick Unlock", "menu"))
choice = input("Select an option or press Enter to go back: ").strip()
if choice == "1":
@@ -929,7 +1006,16 @@ def handle_settings(password_manager: PasswordManager) -> None:
elif choice == "2":
handle_nostr_menu(password_manager)
elif choice == "3":
password_manager.change_password()
try:
old_pw = prompt_existing_password("Enter your current password: ")
new_pw = prompt_new_password()
password_manager.change_password(old_pw, new_pw)
except ValueError:
print(colored("Incorrect password.", "red"))
except PasswordPromptError:
pass
except Exception as e:
print(colored(f"Error: {e}", "red"))
pause()
elif choice == "4":
password_manager.handle_verify_checksum()
@@ -1007,6 +1093,7 @@ def display_menu(
getattr(password_manager, "start_background_relay_check", lambda: None)()
_display_live_stats(password_manager)
while True:
getattr(password_manager, "poll_background_errors", lambda: None)()
fp, parent_fp, child_fp = getattr(
password_manager,
"header_fingerprint_args",
@@ -1027,11 +1114,15 @@ def display_menu(
getattr(password_manager, "start_background_relay_check", lambda: None)()
continue
# Periodically push updates to Nostr
if (
password_manager.is_dirty
and time.time() - password_manager.last_update >= sync_interval
):
handle_post_to_nostr(password_manager)
current_fp = getattr(password_manager, "current_fingerprint", None)
if current_fp:
if (
password_manager.is_dirty
and time.time() - password_manager.last_update >= sync_interval
):
handle_post_to_nostr(password_manager)
password_manager.is_dirty = False
else:
password_manager.is_dirty = False
# Flush logging handlers
@@ -1121,17 +1212,6 @@ def display_menu(
elif choice == "2":
password_manager.update_activity()
password_manager.handle_retrieve_entry()
fp, parent_fp, child_fp = getattr(
password_manager,
"header_fingerprint_args",
(getattr(password_manager, "current_fingerprint", None), None, None),
)
clear_header_with_notification(
fp,
"Main Menu",
parent_fingerprint=parent_fp,
child_fingerprint=child_fp,
)
elif choice == "3":
password_manager.update_activity()
password_manager.handle_search_entries()
@@ -1165,6 +1245,7 @@ def main(argv: list[str] | None = None, *, fingerprint: str | None = None) -> in
Optional seed profile fingerprint to select automatically.
"""
configure_logging()
_warn_missing_optional_dependencies()
initialize_app()
logger = logging.getLogger(__name__)
logger.info("Starting SeedPass Password Manager")
@@ -1172,10 +1253,35 @@ def main(argv: list[str] | None = None, *, fingerprint: str | None = None) -> in
load_global_config()
parser = argparse.ArgumentParser()
parser.add_argument("--fingerprint")
parser.add_argument(
"--restore-backup",
help="Restore index from backup file before starting",
)
parser.add_argument(
"--no-clipboard",
action="store_true",
help="Disable clipboard support and print secrets",
)
parser.add_argument(
"--deterministic-totp",
action="store_true",
help="Derive TOTP secrets deterministically",
)
parser.add_argument(
"--max-prompt-attempts",
type=int,
default=None,
help="Maximum number of password/seed prompt attempts (0 to disable)",
)
sub = parser.add_subparsers(dest="command")
exp = sub.add_parser("export")
exp.add_argument("--file")
exp.add_argument(
"--unencrypted",
action="store_true",
help="Export without encryption",
)
imp = sub.add_parser("import")
imp.add_argument("--file")
@@ -1191,6 +1297,44 @@ def main(argv: list[str] | None = None, *, fingerprint: str | None = None) -> in
args = parser.parse_args(argv)
if args.restore_backup:
fp_target = args.fingerprint or fingerprint
if fp_target is None:
print(
colored(
"Error: --fingerprint is required when using --restore-backup.",
"red",
)
)
return 1
try:
restore_backup_index(Path(args.restore_backup), fp_target)
logger.info("Restored backup from %s", args.restore_backup)
except Exception as e:
logger.error(f"Failed to restore backup: {e}", exc_info=True)
print(colored(f"Error: Failed to restore backup: {e}", "red"))
return 1
elif args.command is None:
print("Startup Options:")
print("1. Continue")
print("2. Restore from backup")
choice = input("Select an option: ").strip()
if choice == "2":
path = input("Enter backup file path: ").strip()
fp_target = args.fingerprint or fingerprint
if fp_target is None:
fp_target = input("Enter fingerprint for restore: ").strip()
try:
restore_backup_index(Path(path), fp_target)
logger.info("Restored backup from %s", path)
except Exception as e:
logger.error(f"Failed to restore backup: {e}", exc_info=True)
print(colored(f"Error: Failed to restore backup: {e}", "red"))
return 1
if args.max_prompt_attempts is not None:
os.environ["SEEDPASS_MAX_PROMPT_ATTEMPTS"] = str(args.max_prompt_attempts)
try:
password_manager = PasswordManager(fingerprint=args.fingerprint or fingerprint)
logger.info("PasswordManager initialized successfully.")
@@ -1203,8 +1347,15 @@ def main(argv: list[str] | None = None, *, fingerprint: str | None = None) -> in
print(colored(f"Error: Failed to initialize PasswordManager: {e}", "red"))
return 1
if args.no_clipboard:
password_manager.secret_mode_enabled = False
if args.deterministic_totp:
password_manager.deterministic_totp = True
if args.command == "export":
password_manager.handle_export_database(Path(args.file))
password_manager.handle_export_database(
Path(args.file), encrypt=not args.unencrypted
)
return 0
if args.command == "import":
password_manager.handle_import_database(Path(args.file))
@@ -1246,15 +1397,22 @@ def main(argv: list[str] | None = None, *, fingerprint: str | None = None) -> in
if entry.get("type") != EntryType.TOTP.value:
print(colored("Entry is not a TOTP entry.", "red"))
return 1
code = password_manager.entry_manager.get_totp_code(
idx, password_manager.parent_seed
key = getattr(password_manager, "KEY_TOTP_DET", None) or getattr(
password_manager, "parent_seed", None
)
code = password_manager.entry_manager.get_totp_code(idx, key)
print(code)
try:
copy_to_clipboard(code, password_manager.clipboard_clear_delay)
print(colored("Code copied to clipboard", "green"))
except Exception as exc:
logging.warning(f"Clipboard copy failed: {exc}")
if copy_to_clipboard(code, password_manager.clipboard_clear_delay):
print(colored("Code copied to clipboard", "green"))
except ClipboardUnavailableError as exc:
print(
colored(
f"Clipboard unavailable: {exc}\n"
"Re-run with '--no-clipboard' to print codes instead.",
"yellow",
)
)
return 0
def signal_handler(sig, _frame):
+1
View File
@@ -25,3 +25,4 @@ class Manifest:
algo: str
chunks: List[ChunkMeta]
delta_since: Optional[int] = None
nonce: Optional[str] = None
+29 -617
View File
@@ -1,36 +1,39 @@
# src/nostr/client.py
import asyncio
import base64
import json
import logging
import time
from typing import List, Optional, Tuple, TYPE_CHECKING
import hashlib
import asyncio
import gzip
import threading
import websockets
from datetime import timedelta
from typing import List, Optional, TYPE_CHECKING
# Imports from the nostr-sdk library
import websockets
from nostr_sdk import (
Client,
Keys,
NostrSigner,
EventBuilder,
Filter,
Kind,
KindStandard,
NostrSigner,
Tag,
RelayUrl,
PublicKey,
)
from datetime import timedelta
from nostr_sdk import EventId, Timestamp
from nostr_sdk import EventId, Keys, Timestamp
from .key_manager import KeyManager as SeedPassKeyManager
from .backup_models import Manifest, ChunkMeta, KIND_MANIFEST, KIND_SNAPSHOT_CHUNK
from seedpass.core.encryption import EncryptionManager
from constants import MAX_RETRIES, RETRY_DELAY
from utils.file_lock import exclusive_lock
from seedpass.core.encryption import EncryptionManager
from .backup_models import (
ChunkMeta,
KIND_DELTA,
KIND_MANIFEST,
KIND_SNAPSHOT_CHUNK,
Manifest,
)
from .connection import ConnectionHandler, DEFAULT_RELAYS
from .key_manager import KeyManager as SeedPassKeyManager
from .snapshot import SnapshotHandler, prepare_snapshot
if TYPE_CHECKING: # pragma: no cover - imported for type hints
from seedpass.core.config_manager import ConfigManager
@@ -42,56 +45,8 @@ ClientBuilder = Client
logger = logging.getLogger(__name__)
logger.setLevel(logging.WARNING)
DEFAULT_RELAYS = [
"wss://relay.snort.social",
"wss://nostr.oxtr.dev",
"wss://relay.primal.net",
]
# Identifier prefix for replaceable manifest events
MANIFEST_ID_PREFIX = "seedpass-manifest-"
def prepare_snapshot(
encrypted_bytes: bytes, limit: int
) -> Tuple[Manifest, list[bytes]]:
"""Compress and split the encrypted vault into chunks.
Each chunk is hashed with SHA-256 and described in the returned
:class:`Manifest`.
Parameters
----------
encrypted_bytes : bytes
The encrypted vault contents.
limit : int
Maximum chunk size in bytes.
Returns
-------
Tuple[Manifest, list[bytes]]
The manifest describing all chunks and the list of chunk bytes.
"""
compressed = gzip.compress(encrypted_bytes)
chunks = [compressed[i : i + limit] for i in range(0, len(compressed), limit)]
metas: list[ChunkMeta] = []
for i, chunk in enumerate(chunks):
metas.append(
ChunkMeta(
id=f"seedpass-chunk-{i:04d}",
size=len(chunk),
hash=hashlib.sha256(chunk).hexdigest(),
event_id=None,
)
)
manifest = Manifest(ver=1, algo="gzip", chunks=metas)
return manifest, chunks
class NostrClient:
class NostrClient(ConnectionHandler, SnapshotHandler):
"""Interact with the Nostr network using nostr-sdk."""
def __init__(
@@ -102,6 +57,8 @@ class NostrClient:
parent_seed: Optional[str] = None,
offline_mode: bool = False,
config_manager: Optional["ConfigManager"] = None,
key_index: bytes | None = None,
account_index: int | None = None,
) -> None:
self.encryption_manager = encryption_manager
self.fingerprint = fingerprint
@@ -113,7 +70,7 @@ class NostrClient:
parent_seed = self.encryption_manager.decrypt_parent_seed()
# Use our project's KeyManager to derive the private key
self.key_manager = KeyManager(parent_seed, fingerprint)
self.key_manager = KeyManager(parent_seed, fingerprint, account_index)
# Create a nostr-sdk Keys object from our derived private key
private_key_hex = self.key_manager.keys.private_key_hex()
@@ -144,6 +101,7 @@ class NostrClient:
self.current_manifest: Manifest | None = None
self.current_manifest_id: str | None = None
self._delta_events: list[str] = []
self.key_index = key_index or b""
# Configure and initialize the nostr-sdk Client
signer = NostrSigner.keys(self.keys)
@@ -151,555 +109,9 @@ class NostrClient:
self._connected = False
def connect(self) -> None:
"""Connect the client to all configured relays."""
if self.offline_mode or not self.relays:
return
if not self._connected:
self.initialize_client_pool()
def initialize_client_pool(self) -> None:
"""Add relays to the client and connect."""
if self.offline_mode or not self.relays:
return
asyncio.run(self._initialize_client_pool())
async def _connect_async(self) -> None:
"""Ensure the client is connected within an async context."""
if self.offline_mode or not self.relays:
return
if not self._connected:
await self._initialize_client_pool()
async def _initialize_client_pool(self) -> None:
if self.offline_mode or not self.relays:
return
formatted = []
for relay in self.relays:
if isinstance(relay, str):
try:
formatted.append(RelayUrl.parse(relay))
except Exception:
logger.error("Invalid relay URL: %s", relay)
else:
formatted.append(relay)
if hasattr(self.client, "add_relays"):
await self.client.add_relays(formatted)
else:
for relay in formatted:
await self.client.add_relay(relay)
await self.client.connect()
self._connected = True
logger.info("NostrClient connected to relays: %s", formatted)
async def _ping_relay(self, relay: str, timeout: float) -> bool:
"""Attempt to retrieve the latest event from a single relay."""
sub_id = "seedpass-health"
pubkey = self.keys.public_key().to_hex()
req = json.dumps(
["REQ", sub_id, {"kinds": [1], "authors": [pubkey], "limit": 1}]
)
try:
async with websockets.connect(
relay, open_timeout=timeout, close_timeout=timeout
) as ws:
await ws.send(req)
while True:
msg = await asyncio.wait_for(ws.recv(), timeout=timeout)
data = json.loads(msg)
if data[0] in {"EVENT", "EOSE"}:
return True
except Exception:
return False
async def _check_relay_health(self, min_relays: int, timeout: float) -> int:
tasks = [self._ping_relay(r, timeout) for r in self.relays]
results = await asyncio.gather(*tasks, return_exceptions=True)
healthy = sum(1 for r in results if r is True)
if healthy < min_relays:
logger.warning(
"Only %s relays responded with data; consider adding more.", healthy
)
return healthy
def check_relay_health(self, min_relays: int = 2, timeout: float = 5.0) -> int:
"""Ping relays and return the count of those providing data."""
if self.offline_mode or not self.relays:
return 0
return asyncio.run(self._check_relay_health(min_relays, timeout))
def publish_json_to_nostr(
self,
encrypted_json: bytes,
to_pubkey: str | None = None,
alt_summary: str | None = None,
) -> str | None:
"""Builds and publishes a Kind 1 text note or direct message.
Parameters
----------
encrypted_json : bytes
The encrypted index data to publish.
to_pubkey : str | None, optional
If provided, send as a direct message to this public key.
alt_summary : str | None, optional
If provided, include an ``alt`` tag so uploads can be
associated with a specific event like a password change.
"""
if self.offline_mode or not self.relays:
return None
self.connect()
self.last_error = None
try:
content = base64.b64encode(encrypted_json).decode("utf-8")
if to_pubkey:
receiver = PublicKey.parse(to_pubkey)
event_output = self.client.send_private_msg_to(
self.relays, receiver, content
)
else:
builder = EventBuilder.text_note(content)
if alt_summary:
builder = builder.tags([Tag.alt(alt_summary)])
event = builder.build(self.keys.public_key()).sign_with_keys(self.keys)
event_output = self.publish_event(event)
event_id_hex = (
event_output.id.to_hex()
if hasattr(event_output, "id")
else str(event_output)
)
logger.info(f"Successfully published event with ID: {event_id_hex}")
return event_id_hex
except Exception as e:
self.last_error = str(e)
logger.error(f"Failed to publish JSON to Nostr: {e}")
return None
def publish_event(self, event):
"""Publish a prepared event to the configured relays."""
if self.offline_mode or not self.relays:
return None
self.connect()
return asyncio.run(self._publish_event(event))
async def _publish_event(self, event):
if self.offline_mode or not self.relays:
return None
await self._connect_async()
return await self.client.send_event(event)
def update_relays(self, new_relays: List[str]) -> None:
"""Reconnect the client using a new set of relays."""
self.close_client_pool()
self.relays = new_relays
signer = NostrSigner.keys(self.keys)
self.client = Client(signer)
self._connected = False
# Immediately reconnect using the updated relay list
self.initialize_client_pool()
def retrieve_json_from_nostr_sync(
self, retries: int | None = None, delay: float | None = None
) -> Optional[bytes]:
"""Retrieve the latest Kind 1 event from the author with optional retries."""
if self.offline_mode or not self.relays:
return None
if retries is None or delay is None:
if self.config_manager is None:
from seedpass.core.config_manager import ConfigManager
from seedpass.core.vault import Vault
cfg_mgr = ConfigManager(
Vault(self.encryption_manager, self.fingerprint_dir),
self.fingerprint_dir,
)
else:
cfg_mgr = self.config_manager
cfg = cfg_mgr.load_config(require_pin=False)
retries = int(cfg.get("nostr_max_retries", MAX_RETRIES))
delay = float(cfg.get("nostr_retry_delay", RETRY_DELAY))
self.connect()
self.last_error = None
for attempt in range(retries):
try:
result = asyncio.run(self._retrieve_json_from_nostr())
if result is not None:
return result
except Exception as e:
self.last_error = str(e)
logger.error("Failed to retrieve events from Nostr: %s", e)
if attempt < retries - 1:
sleep_time = delay * (2**attempt)
time.sleep(sleep_time)
return None
async def _retrieve_json_from_nostr(self) -> Optional[bytes]:
if self.offline_mode or not self.relays:
return None
await self._connect_async()
# Filter for the latest text note (Kind 1) from our public key
pubkey = self.keys.public_key()
f = Filter().author(pubkey).kind(Kind.from_std(KindStandard.TEXT_NOTE)).limit(1)
timeout = timedelta(seconds=10)
events = (await self.client.fetch_events(f, timeout)).to_vec()
if not events:
self.last_error = "No events found on relays for this user."
logger.warning(self.last_error)
return None
latest_event = events[0]
content_b64 = latest_event.content()
if content_b64:
return base64.b64decode(content_b64.encode("utf-8"))
self.last_error = "Latest event contained no content"
return None
async def publish_snapshot(
self, encrypted_bytes: bytes, limit: int = 50_000
) -> tuple[Manifest, str]:
"""Publish a compressed snapshot split into chunks.
Parameters
----------
encrypted_bytes : bytes
Vault contents already encrypted with the user's key.
limit : int, optional
Maximum chunk size in bytes. Defaults to 50 kB.
"""
start = time.perf_counter()
if self.offline_mode or not self.relays:
return Manifest(ver=1, algo="gzip", chunks=[]), ""
await self.ensure_manifest_is_current()
await self._connect_async()
manifest, chunks = prepare_snapshot(encrypted_bytes, limit)
for meta, chunk in zip(manifest.chunks, chunks):
content = base64.b64encode(chunk).decode("utf-8")
builder = EventBuilder(Kind(KIND_SNAPSHOT_CHUNK), content).tags(
[Tag.identifier(meta.id)]
)
event = builder.build(self.keys.public_key()).sign_with_keys(self.keys)
result = await self.client.send_event(event)
try:
meta.event_id = (
result.id.to_hex() if hasattr(result, "id") else str(result)
)
except Exception:
meta.event_id = None
manifest_json = json.dumps(
{
"ver": manifest.ver,
"algo": manifest.algo,
"chunks": [meta.__dict__ for meta in manifest.chunks],
"delta_since": manifest.delta_since,
}
)
manifest_identifier = f"{MANIFEST_ID_PREFIX}{self.fingerprint}"
manifest_event = (
EventBuilder(Kind(KIND_MANIFEST), manifest_json)
.tags([Tag.identifier(manifest_identifier)])
.build(self.keys.public_key())
.sign_with_keys(self.keys)
)
await self.client.send_event(manifest_event)
with self._state_lock:
self.current_manifest = manifest
self.current_manifest_id = manifest_identifier
# Record when this snapshot was published for future delta events
self.current_manifest.delta_since = int(time.time())
self._delta_events = []
if getattr(self, "verbose_timing", False):
duration = time.perf_counter() - start
logger.info("publish_snapshot completed in %.2f seconds", duration)
return manifest, manifest_identifier
async def _fetch_single_chunk(
self, meta: ChunkMeta, pubkey, max_retries: int, delay: float
) -> bytes | None:
"""Fetch a single chunk with retries."""
timeout = timedelta(seconds=10)
for attempt in range(max_retries):
cf = Filter().author(pubkey).kind(Kind(KIND_SNAPSHOT_CHUNK))
if meta.event_id:
cf = cf.id(EventId.parse(meta.event_id))
else:
cf = cf.identifier(meta.id)
cf = cf.limit(1)
cev = (await self.client.fetch_events(cf, timeout)).to_vec()
if cev:
candidate = base64.b64decode(cev[0].content().encode("utf-8"))
if hashlib.sha256(candidate).hexdigest() == meta.hash:
return candidate
if attempt < max_retries - 1:
await asyncio.sleep(delay * (2**attempt))
return None
async def _fetch_chunks_with_retry(
self, manifest_event
) -> tuple[Manifest, list[bytes]] | None:
"""Retrieve all chunks referenced by ``manifest_event`` with retries."""
pubkey = self.keys.public_key()
# timeout is used inside _fetch_single_chunk
try:
data = json.loads(manifest_event.content())
manifest = Manifest(
ver=data["ver"],
algo=data["algo"],
chunks=[ChunkMeta(**c) for c in data["chunks"]],
delta_since=(
int(data["delta_since"])
if data.get("delta_since") is not None
else None
),
)
except Exception:
return None
if self.config_manager is None:
from seedpass.core.config_manager import ConfigManager
from seedpass.core.vault import Vault
cfg_mgr = ConfigManager(
Vault(self.encryption_manager, self.fingerprint_dir),
self.fingerprint_dir,
)
else:
cfg_mgr = self.config_manager
cfg = cfg_mgr.load_config(require_pin=False)
max_retries = int(cfg.get("nostr_max_retries", MAX_RETRIES))
delay = float(cfg.get("nostr_retry_delay", RETRY_DELAY))
# Limit concurrency to 10 to avoid overwhelming relays
semaphore = asyncio.Semaphore(10)
async def fetch_with_semaphore(meta: ChunkMeta) -> bytes | None:
async with semaphore:
return await self._fetch_single_chunk(meta, pubkey, max_retries, delay)
tasks = [fetch_with_semaphore(meta) for meta in manifest.chunks]
results = await asyncio.gather(*tasks)
chunks: list[bytes] = []
for res in results:
if res is None:
return None
chunks.append(res)
ident = None
try:
tags_obj = manifest_event.tags()
ident = tags_obj.identifier()
except Exception:
tags = getattr(manifest_event, "tags", None)
if callable(tags):
tags = tags()
if tags:
tag = tags[0]
if hasattr(tag, "as_vec"):
vec = tag.as_vec()
if vec and len(vec) >= 2:
ident = vec[1]
elif isinstance(tag, (list, tuple)) and len(tag) >= 2:
ident = tag[1]
elif isinstance(tag, str):
ident = tag
with self._state_lock:
self.current_manifest = manifest
self.current_manifest_id = ident
return manifest, chunks
async def fetch_latest_snapshot(self) -> Tuple[Manifest, list[bytes]] | None:
"""Retrieve the latest manifest and all snapshot chunks."""
if self.offline_mode or not self.relays:
return None
await self._connect_async()
self.last_error = None
pubkey = self.keys.public_key()
ident = f"{MANIFEST_ID_PREFIX}{self.fingerprint}"
f = Filter().author(pubkey).kind(Kind(KIND_MANIFEST)).identifier(ident).limit(1)
timeout = timedelta(seconds=10)
try:
events = (await self.client.fetch_events(f, timeout)).to_vec()
except Exception as e: # pragma: no cover - network errors
self.last_error = str(e)
logger.error(
"Failed to fetch manifest from relays %s: %s",
self.relays,
e,
)
return None
if not events:
return None
for manifest_event in events:
try:
result = await self._fetch_chunks_with_retry(manifest_event)
if result is not None:
return result
except Exception as e: # pragma: no cover - network errors
self.last_error = str(e)
logger.error(
"Error retrieving snapshot from relays %s: %s",
self.relays,
e,
)
if self.last_error is None:
self.last_error = "Snapshot not found on relays"
return None
async def ensure_manifest_is_current(self) -> None:
"""Verify the local manifest is up to date before publishing."""
if self.offline_mode or not self.relays:
return
await self._connect_async()
pubkey = self.keys.public_key()
ident = f"{MANIFEST_ID_PREFIX}{self.fingerprint}"
f = Filter().author(pubkey).kind(Kind(KIND_MANIFEST)).identifier(ident).limit(1)
timeout = timedelta(seconds=10)
try:
events = (await self.client.fetch_events(f, timeout)).to_vec()
except Exception:
return
if not events:
return
try:
data = json.loads(events[0].content())
remote = data.get("delta_since")
if remote is not None:
remote = int(remote)
except Exception:
return
with self._state_lock:
local = self.current_manifest.delta_since if self.current_manifest else None
if remote is not None and (local is None or remote > local):
self.last_error = "Manifest out of date"
raise RuntimeError("Manifest out of date")
async def publish_delta(self, delta_bytes: bytes, manifest_id: str) -> str:
"""Publish a delta event referencing a manifest."""
if self.offline_mode or not self.relays:
return ""
await self.ensure_manifest_is_current()
await self._connect_async()
content = base64.b64encode(delta_bytes).decode("utf-8")
tag = Tag.event(EventId.parse(manifest_id))
builder = EventBuilder(Kind(KIND_DELTA), content).tags([tag])
event = builder.build(self.keys.public_key()).sign_with_keys(self.keys)
result = await self.client.send_event(event)
delta_id = result.id.to_hex() if hasattr(result, "id") else str(result)
created_at = getattr(
event, "created_at", getattr(event, "timestamp", int(time.time()))
)
if hasattr(created_at, "secs"):
created_at = created_at.secs
manifest_event = None
with self._state_lock:
if self.current_manifest is not None:
self.current_manifest.delta_since = int(created_at)
manifest_json = json.dumps(
{
"ver": self.current_manifest.ver,
"algo": self.current_manifest.algo,
"chunks": [
meta.__dict__ for meta in self.current_manifest.chunks
],
"delta_since": self.current_manifest.delta_since,
}
)
manifest_event = (
EventBuilder(Kind(KIND_MANIFEST), manifest_json)
.tags([Tag.identifier(self.current_manifest_id)])
.build(self.keys.public_key())
.sign_with_keys(self.keys)
)
self._delta_events.append(delta_id)
if manifest_event is not None:
await self.client.send_event(manifest_event)
return delta_id
async def fetch_deltas_since(self, version: int) -> list[bytes]:
"""Retrieve delta events newer than the given version."""
if self.offline_mode or not self.relays:
return []
await self._connect_async()
pubkey = self.keys.public_key()
f = (
Filter()
.author(pubkey)
.kind(Kind(KIND_DELTA))
.since(Timestamp.from_secs(version))
)
timeout = timedelta(seconds=10)
events = (await self.client.fetch_events(f, timeout)).to_vec()
events.sort(
key=lambda ev: getattr(ev, "created_at", getattr(ev, "timestamp", 0))
)
deltas: list[bytes] = []
for ev in events:
deltas.append(base64.b64decode(ev.content().encode("utf-8")))
manifest = self.get_current_manifest()
if manifest is not None:
snap_size = sum(c.size for c in manifest.chunks)
if (
len(deltas) >= self.delta_threshold
or sum(len(d) for d in deltas) > snap_size
):
# Publish a new snapshot to consolidate deltas
joined = b"".join(deltas)
await self.publish_snapshot(joined)
exp = Timestamp.from_secs(int(time.time()))
for ev in events:
exp_builder = EventBuilder(Kind(KIND_DELTA), ev.content()).tags(
[Tag.expiration(exp)]
)
exp_event = exp_builder.build(
self.keys.public_key()
).sign_with_keys(self.keys)
await self.client.send_event(exp_event)
return deltas
def get_current_manifest(self) -> Manifest | None:
"""Thread-safe access to ``current_manifest``."""
with self._state_lock:
return self.current_manifest
def get_current_manifest_id(self) -> str | None:
"""Thread-safe access to ``current_manifest_id``."""
with self._state_lock:
return self.current_manifest_id
def get_delta_events(self) -> list[str]:
"""Thread-safe snapshot of pending delta event IDs."""
with self._state_lock:
return list(self._delta_events)
def close_client_pool(self) -> None:
"""Disconnects the client from all relays."""
try:
asyncio.run(self.client.disconnect())
self._connected = False
logger.info("NostrClient disconnected from relays.")
except Exception as e:
logger.error("Error during NostrClient shutdown: %s", e)
__all__ = [
"NostrClient",
"prepare_snapshot",
"DEFAULT_RELAYS",
]
+2 -1
View File
@@ -27,7 +27,8 @@ class Keys:
@staticmethod
def hex_to_bech32(key_str: str, prefix: str = "npub") -> str:
data = convertbits(bytes.fromhex(key_str), 8, 5)
# Pad to align with 5-bit groups as expected for Bech32 encoding
data = convertbits(bytes.fromhex(key_str), 8, 5, True)
return bech32_encode(prefix, data)
@staticmethod
+232
View File
@@ -0,0 +1,232 @@
import asyncio
import base64
import json
import logging
from datetime import timedelta
from typing import List, Optional
import websockets
from . import client as nostr_client
from constants import MAX_RETRIES, RETRY_DELAY
logger = logging.getLogger("nostr.client")
logger.setLevel(logging.WARNING)
DEFAULT_RELAYS = [
"wss://relay.snort.social",
"wss://nostr.oxtr.dev",
"wss://relay.primal.net",
]
class ConnectionHandler:
"""Mixin providing relay connection and retry logic."""
async def connect(self) -> None:
"""Connect the client to all configured relays."""
if self.offline_mode or not self.relays:
return
if not getattr(self, "_connected", False):
await self._initialize_client_pool()
def initialize_client_pool(self) -> None:
"""Add relays to the client and connect."""
if self.offline_mode or not self.relays:
return
asyncio.run(self._initialize_client_pool())
async def _connect_async(self) -> None:
"""Ensure the client is connected within an async context."""
if self.offline_mode or not self.relays:
return
if not getattr(self, "_connected", False):
await self._initialize_client_pool()
async def _initialize_client_pool(self) -> None:
if self.offline_mode or not self.relays:
return
formatted = []
for relay in self.relays:
if isinstance(relay, str):
try:
formatted.append(nostr_client.RelayUrl.parse(relay))
except Exception:
logger.error("Invalid relay URL: %s", relay)
else:
formatted.append(relay)
if hasattr(self.client, "add_relays"):
await self.client.add_relays(formatted)
else:
for relay in formatted:
await self.client.add_relay(relay)
await self.client.connect()
self._connected = True
logger.info("NostrClient connected to relays: %s", formatted)
async def _ping_relay(self, relay: str, timeout: float) -> bool:
"""Attempt to retrieve the latest event from a single relay."""
sub_id = "seedpass-health"
pubkey = self.keys.public_key().to_hex()
req = json.dumps(
[
"REQ",
sub_id,
{"kinds": [1], "authors": [pubkey], "limit": 1},
]
)
try:
async with websockets.connect(
relay, open_timeout=timeout, close_timeout=timeout
) as ws:
await ws.send(req)
while True:
msg = await asyncio.wait_for(ws.recv(), timeout=timeout)
data = json.loads(msg)
if data[0] in {"EVENT", "EOSE"}:
return True
except Exception:
return False
async def _check_relay_health(self, min_relays: int, timeout: float) -> int:
tasks = [self._ping_relay(r, timeout) for r in self.relays]
results = await asyncio.gather(*tasks, return_exceptions=True)
healthy = sum(1 for r in results if r is True)
if healthy < min_relays:
logger.warning(
"Only %s relays responded with data; consider adding more.", healthy
)
return healthy
def check_relay_health(self, min_relays: int = 2, timeout: float = 5.0) -> int:
"""Ping relays and return the count of those providing data."""
if self.offline_mode or not self.relays:
return 0
return asyncio.run(self._check_relay_health(min_relays, timeout))
async def publish_json_to_nostr(
self,
encrypted_json: bytes,
to_pubkey: str | None = None,
alt_summary: str | None = None,
) -> str | None:
"""Build and publish a Kind 1 text note or direct message."""
if self.offline_mode or not self.relays:
return None
await self.connect()
self.last_error = None
try:
content = base64.b64encode(encrypted_json).decode("utf-8")
if to_pubkey:
receiver = nostr_client.PublicKey.parse(to_pubkey)
event_output = self.client.send_private_msg_to(
self.relays, receiver, content
)
else:
builder = nostr_client.EventBuilder.text_note(content)
if alt_summary:
builder = builder.tags([nostr_client.Tag.alt(alt_summary)])
event = builder.build(self.keys.public_key()).sign_with_keys(self.keys)
event_output = await self.publish_event(event)
event_id_hex = (
event_output.id.to_hex()
if hasattr(event_output, "id")
else str(event_output)
)
logger.info("Successfully published event with ID: %s", event_id_hex)
return event_id_hex
except Exception as e:
self.last_error = str(e)
logger.error("Failed to publish JSON to Nostr: %s", e)
return None
async def publish_event(self, event):
"""Publish a prepared event to the configured relays."""
if self.offline_mode or not self.relays:
return None
await self.connect()
return await self.client.send_event(event)
def update_relays(self, new_relays: List[str]) -> None:
"""Reconnect the client using a new set of relays."""
self.close_client_pool()
self.relays = new_relays
signer = nostr_client.NostrSigner.keys(self.keys)
self.client = nostr_client.Client(signer)
self._connected = False
self.initialize_client_pool()
async def retrieve_json_from_nostr(
self, retries: int | None = None, delay: float | None = None
) -> Optional[bytes]:
"""Retrieve the latest Kind 1 event from the author with optional retries."""
if self.offline_mode or not self.relays:
return None
if retries is None or delay is None:
if self.config_manager is None:
from seedpass.core.config_manager import ConfigManager
from seedpass.core.vault import Vault
cfg_mgr = ConfigManager(
Vault(self.encryption_manager, self.fingerprint_dir),
self.fingerprint_dir,
)
else:
cfg_mgr = self.config_manager
cfg = cfg_mgr.load_config(require_pin=False)
retries = int(cfg.get("nostr_max_retries", MAX_RETRIES))
delay = float(cfg.get("nostr_retry_delay", RETRY_DELAY))
await self.connect()
self.last_error = None
for attempt in range(retries):
try:
result = await self._retrieve_json_from_nostr()
if result is not None:
return result
except Exception as e:
self.last_error = str(e)
logger.error("Failed to retrieve events from Nostr: %s", e)
if attempt < retries - 1:
sleep_time = delay * (2**attempt)
await asyncio.sleep(sleep_time)
return None
async def _retrieve_json_from_nostr(self) -> Optional[bytes]:
if self.offline_mode or not self.relays:
return None
await self._connect_async()
pubkey = self.keys.public_key()
f = (
nostr_client.Filter()
.author(pubkey)
.kind(nostr_client.Kind.from_std(nostr_client.KindStandard.TEXT_NOTE))
.limit(1)
)
timeout = timedelta(seconds=10)
events = (await self.client.fetch_events(f, timeout)).to_vec()
if not events:
self.last_error = "No events found on relays for this user."
logger.warning(self.last_error)
return None
latest_event = events[0]
content_b64 = latest_event.content()
if content_b64:
return base64.b64decode(content_b64.encode("utf-8"))
self.last_error = "Latest event contained no content"
return None
def close_client_pool(self) -> None:
"""Disconnect the client from all relays."""
try:
asyncio.run(self.client.disconnect())
self._connected = False
logger.info("NostrClient disconnected from relays.")
except Exception as e:
logger.error("Error during NostrClient shutdown: %s", e)
-52
View File
@@ -1,52 +0,0 @@
# nostr/event_handler.py
import time
import logging
import traceback
try:
from monstr.event.event import Event
except ImportError: # pragma: no cover - optional dependency
class Event: # minimal placeholder for type hints when monstr is absent
id: str
created_at: int
content: str
# Instantiate the logger
logger = logging.getLogger(__name__)
class EventHandler:
"""
Handles incoming Nostr events.
"""
def __init__(self):
pass # Initialize if needed
def handle_new_event(self, evt: Event):
"""
Processes incoming events by logging their details.
:param evt: The received Event object.
"""
try:
# Assuming evt.created_at is always an integer Unix timestamp
if isinstance(evt.created_at, int):
created_at_str = time.strftime(
"%Y-%m-%d %H:%M:%S", time.gmtime(evt.created_at)
)
else:
# Handle unexpected types gracefully
created_at_str = str(evt.created_at)
# Log the event details without extra newlines
logger.info(
f"[New Event] ID: {evt.id} | Created At: {created_at_str} | Content: {evt.content}"
)
except Exception as e:
logger.error(f"Error handling new event: {e}", exc_info=True)
# Optionally, handle the exception without re-raising
# For example, continue processing other events
+52 -27
View File
@@ -2,28 +2,36 @@
import hashlib
import logging
import traceback
from bech32 import bech32_encode, convertbits
from local_bip85.bip85 import BIP85
from bip_utils import Bip39SeedGenerator
from .coincurve_keys import Keys
# BIP-85 application numbers for Nostr key derivation
NOSTR_KEY_APP_ID = 1237
LEGACY_NOSTR_KEY_APP_ID = 0
logger = logging.getLogger(__name__)
class KeyManager:
"""
Manages key generation, encoding, and derivation for NostrClient.
"""
"""Manages key generation, encoding, and derivation for ``NostrClient``."""
def __init__(self, parent_seed: str, fingerprint: str):
"""
Initializes the KeyManager with the provided parent_seed and fingerprint.
def __init__(
self, parent_seed: str, fingerprint: str, account_index: int | None = None
):
"""Initialize the key manager.
Parameters:
parent_seed (str): The parent seed used for key derivation.
fingerprint (str): The fingerprint to differentiate key derivations.
Parameters
----------
parent_seed:
The BIP-39 seed used as the root for derivations.
fingerprint:
Seed profile fingerprint used for legacy derivations and logging.
account_index:
Optional explicit index for BIP-85 Nostr key derivation. When ``None``
the index defaults to ``0``.
"""
try:
if not isinstance(parent_seed, str):
@@ -37,12 +45,15 @@ class KeyManager:
self.parent_seed = parent_seed
self.fingerprint = fingerprint
logger.debug(f"KeyManager initialized with parent_seed and fingerprint.")
self.account_index = account_index
logger.debug(
"KeyManager initialized with parent_seed, fingerprint and account index."
)
# Initialize BIP85
self.bip85 = self.initialize_bip85()
# Generate Nostr keys using the fingerprint
# Generate Nostr keys using the provided account index
self.keys = self.generate_nostr_keys()
logger.debug("Nostr Keys initialized successfully.")
@@ -67,33 +78,47 @@ class KeyManager:
raise
def generate_nostr_keys(self) -> Keys:
"""
Derives a unique Nostr key pair for the given fingerprint using BIP-85.
Returns:
Keys: An instance of Keys containing the Nostr key pair.
"""
"""Derive a Nostr key pair using the configured ``account_index``."""
try:
# Convert fingerprint to an integer index (using a hash function)
index = int(hashlib.sha256(self.fingerprint.encode()).hexdigest(), 16) % (
2**31
)
index = self.account_index if self.account_index is not None else 0
# Derive entropy for Nostr key (32 bytes)
entropy_bytes = self.bip85.derive_entropy(
index=index,
bytes_len=32, # Adjust parameter name and value as per your method signature
index=index, entropy_bytes=32, app_no=NOSTR_KEY_APP_ID
)
# Generate Nostr key pair from entropy
private_key_hex = entropy_bytes.hex()
keys = Keys(priv_k=private_key_hex)
logger.debug(f"Nostr keys generated for fingerprint {self.fingerprint}.")
logger.debug("Nostr keys generated for account index %s", index)
return keys
except Exception as e:
logger.error(f"Failed to generate Nostr keys: {e}", exc_info=True)
raise
def generate_v1_nostr_keys(self) -> Keys:
"""Derive keys using the legacy fingerprint-hash method."""
try:
index = int(hashlib.sha256(self.fingerprint.encode()).hexdigest(), 16) % (
2**31
)
entropy_bytes = self.bip85.derive_entropy(
index=index, entropy_bytes=32, app_no=NOSTR_KEY_APP_ID
)
return Keys(priv_k=entropy_bytes.hex())
except Exception as e:
logger.error(f"Failed to generate v1 Nostr keys: {e}", exc_info=True)
raise
def generate_legacy_nostr_keys(self) -> Keys:
"""Derive Nostr keys using the legacy application ID."""
try:
entropy = self.bip85.derive_entropy(
index=0, entropy_bytes=32, app_no=LEGACY_NOSTR_KEY_APP_ID
)
return Keys(priv_k=entropy.hex())
except Exception as e:
logger.error(f"Failed to generate legacy Nostr keys: {e}", exc_info=True)
raise
def get_public_key_hex(self) -> str:
"""
Returns the public key in hexadecimal format.
-41
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)
+443
View File
@@ -0,0 +1,443 @@
import asyncio
import base64
import gzip
import hashlib
import hmac
import json
import logging
import os
import time
from datetime import timedelta
from typing import Tuple
from . import client as nostr_client
from constants import MAX_RETRIES, RETRY_DELAY
from .backup_models import (
ChunkMeta,
Manifest,
KIND_DELTA,
KIND_MANIFEST,
KIND_SNAPSHOT_CHUNK,
)
logger = logging.getLogger("nostr.client")
logger.setLevel(logging.WARNING)
def prepare_snapshot(
encrypted_bytes: bytes, limit: int
) -> Tuple[Manifest, list[bytes]]:
"""Compress and split the encrypted vault into chunks."""
compressed = gzip.compress(encrypted_bytes)
chunks = [compressed[i : i + limit] for i in range(0, len(compressed), limit)]
metas: list[ChunkMeta] = []
for i, chunk in enumerate(chunks):
metas.append(
ChunkMeta(
id=f"seedpass-chunk-{i:04d}",
size=len(chunk),
hash=hashlib.sha256(chunk).hexdigest(),
event_id=None,
)
)
manifest = Manifest(ver=1, algo="gzip", chunks=metas)
return manifest, chunks
def new_manifest_id(key_index: bytes) -> tuple[str, bytes]:
"""Return a new manifest identifier and nonce.
The identifier is computed as HMAC-SHA256 of ``b"manifest|" + nonce``
using ``key_index`` as the HMAC key. The nonce is returned so it can be
embedded inside the manifest itself.
"""
nonce = os.urandom(16)
digest = hmac.new(key_index, b"manifest|" + nonce, hashlib.sha256).hexdigest()
return digest, nonce
class SnapshotHandler:
"""Mixin providing chunk and manifest handling."""
async def publish_snapshot(
self, encrypted_bytes: bytes, limit: int = 50_000
) -> tuple[Manifest, str]:
start = time.perf_counter()
if self.offline_mode or not self.relays:
return Manifest(ver=1, algo="gzip", chunks=[]), ""
await self.ensure_manifest_is_current()
await self._connect_async()
manifest, chunks = prepare_snapshot(encrypted_bytes, limit)
existing: dict[str, str] = {}
if self.current_manifest:
for old in self.current_manifest.chunks:
if old.hash and old.event_id:
existing[old.hash] = old.event_id
for meta, chunk in zip(manifest.chunks, chunks):
cached_id = existing.get(meta.hash)
if cached_id:
meta.event_id = cached_id
continue
content = base64.b64encode(chunk).decode("utf-8")
builder = nostr_client.EventBuilder(
nostr_client.Kind(KIND_SNAPSHOT_CHUNK), content
).tags([nostr_client.Tag.identifier(meta.id)])
event = builder.build(self.keys.public_key()).sign_with_keys(self.keys)
result = await self.client.send_event(event)
try:
meta.event_id = (
result.id.to_hex() if hasattr(result, "id") else str(result)
)
except Exception:
meta.event_id = None
if (
self.current_manifest_id
and self.current_manifest
and getattr(self.current_manifest, "nonce", None)
):
manifest_id = self.current_manifest_id
manifest.nonce = self.current_manifest.nonce
else:
manifest_id, nonce = new_manifest_id(self.key_index)
manifest.nonce = base64.b64encode(nonce).decode("utf-8")
manifest_json = json.dumps(
{
"ver": manifest.ver,
"algo": manifest.algo,
"chunks": [meta.__dict__ for meta in manifest.chunks],
"delta_since": manifest.delta_since,
"nonce": manifest.nonce,
}
)
manifest_event = (
nostr_client.EventBuilder(nostr_client.Kind(KIND_MANIFEST), manifest_json)
.tags([nostr_client.Tag.identifier(manifest_id)])
.build(self.keys.public_key())
.sign_with_keys(self.keys)
)
await self.client.send_event(manifest_event)
with self._state_lock:
self.current_manifest = manifest
self.current_manifest_id = manifest_id
self.current_manifest.delta_since = int(time.time())
self._delta_events = []
if getattr(self, "verbose_timing", False):
duration = time.perf_counter() - start
logger.info("publish_snapshot completed in %.2f seconds", duration)
return manifest, manifest_id
async def _fetch_chunks_with_retry(
self, manifest_event
) -> tuple[Manifest, list[bytes]] | None:
pubkey = self.keys.public_key()
timeout = timedelta(seconds=10)
try:
data = json.loads(manifest_event.content())
manifest = Manifest(
ver=data["ver"],
algo=data["algo"],
chunks=[ChunkMeta(**c) for c in data["chunks"]],
delta_since=(
int(data["delta_since"])
if data.get("delta_since") is not None
else None
),
nonce=data.get("nonce"),
)
except Exception:
return None
if self.config_manager is None:
from seedpass.core.config_manager import ConfigManager
from seedpass.core.vault import Vault
cfg_mgr = ConfigManager(
Vault(self.encryption_manager, self.fingerprint_dir),
self.fingerprint_dir,
)
else:
cfg_mgr = self.config_manager
cfg = cfg_mgr.load_config(require_pin=False)
max_retries = int(cfg.get("nostr_max_retries", MAX_RETRIES))
delay = float(cfg.get("nostr_retry_delay", RETRY_DELAY))
chunks: list[bytes] = []
for meta in manifest.chunks:
chunk_bytes: bytes | None = None
for attempt in range(max_retries):
cf = (
nostr_client.Filter()
.author(pubkey)
.kind(nostr_client.Kind(KIND_SNAPSHOT_CHUNK))
)
if meta.event_id:
cf = cf.id(nostr_client.EventId.parse(meta.event_id))
else:
cf = cf.identifier(meta.id)
cf = cf.limit(1)
cev = (await self.client.fetch_events(cf, timeout)).to_vec()
if cev:
candidate = base64.b64decode(cev[0].content().encode("utf-8"))
if hashlib.sha256(candidate).hexdigest() == meta.hash:
chunk_bytes = candidate
break
if attempt < max_retries - 1:
await asyncio.sleep(delay * (2**attempt))
if chunk_bytes is None:
return None
chunks.append(chunk_bytes)
ident = None
try:
tags_obj = manifest_event.tags()
ident = tags_obj.identifier()
except Exception:
tags = getattr(manifest_event, "tags", None)
if callable(tags):
tags = tags()
if tags:
tag = tags[0]
if hasattr(tag, "as_vec"):
vec = tag.as_vec()
if vec and len(vec) >= 2:
ident = vec[1]
elif isinstance(tag, (list, tuple)) and len(tag) >= 2:
ident = tag[1]
elif isinstance(tag, str):
ident = tag
with self._state_lock:
self.current_manifest = manifest
self.current_manifest_id = ident
return manifest, chunks
async def _fetch_manifest_with_keys(
self, keys_obj: nostr_client.Keys
) -> tuple[Manifest, list[bytes]] | None:
"""Retrieve the manifest and chunks using ``keys_obj``."""
self.keys = keys_obj
pubkey = self.keys.public_key()
timeout = timedelta(seconds=10)
ident = self.current_manifest_id
f = nostr_client.Filter().author(pubkey).kind(nostr_client.Kind(KIND_MANIFEST))
if ident:
f = f.identifier(ident)
f = f.limit(1)
try:
events = (await self.client.fetch_events(f, timeout)).to_vec()
except Exception as e: # pragma: no cover - network errors
self.last_error = str(e)
logger.error(
"Failed to fetch manifest from relays %s: %s",
self.relays,
e,
)
return None
if not events and ident:
f = (
nostr_client.Filter()
.author(pubkey)
.kind(nostr_client.Kind(KIND_MANIFEST))
.limit(1)
)
try:
events = (await self.client.fetch_events(f, timeout)).to_vec()
except Exception as e: # pragma: no cover - network errors
self.last_error = str(e)
logger.error(
"Failed to fetch manifest from relays %s: %s",
self.relays,
e,
)
return None
if not events:
return None
for manifest_event in events:
try:
result = await self._fetch_chunks_with_retry(manifest_event)
if result is not None:
return result
except Exception as e: # pragma: no cover - network errors
self.last_error = str(e)
logger.error(
"Error retrieving snapshot from relays %s: %s",
self.relays,
e,
)
return None
async def fetch_latest_snapshot(self) -> Tuple[Manifest, list[bytes]] | None:
"""Retrieve the latest manifest and all snapshot chunks."""
if self.offline_mode or not self.relays:
return None
await self._connect_async()
self.last_error = None
logger.debug("Searching for backup with current keys...")
try:
primary_keys = nostr_client.Keys.parse(
self.key_manager.keys.private_key_hex()
)
except Exception:
primary_keys = self.keys
result = await self._fetch_manifest_with_keys(primary_keys)
if result is not None:
return result
logger.warning(
"No backup found with current keys. Falling back to legacy key derivation..."
)
try:
legacy_keys = self.key_manager.generate_legacy_nostr_keys()
legacy_sdk_keys = nostr_client.Keys.parse(legacy_keys.private_key_hex())
except Exception as e:
self.last_error = str(e)
return None
result = await self._fetch_manifest_with_keys(legacy_sdk_keys)
if result is not None:
logger.info("Found legacy backup with old key derivation.")
return result
if self.last_error is None:
self.last_error = "No backup found on Nostr relays."
return None
async def ensure_manifest_is_current(self) -> None:
"""Verify the local manifest is up to date before publishing."""
if self.offline_mode or not self.relays:
return
await self._connect_async()
pubkey = self.keys.public_key()
ident = self.current_manifest_id
if ident is None:
return
f = (
nostr_client.Filter()
.author(pubkey)
.kind(nostr_client.Kind(KIND_MANIFEST))
.identifier(ident)
.limit(1)
)
timeout = timedelta(seconds=10)
try:
events = (await self.client.fetch_events(f, timeout)).to_vec()
except Exception:
return
if not events:
return
try:
data = json.loads(events[0].content())
remote = data.get("delta_since")
if remote is not None:
remote = int(remote)
except Exception:
return
with self._state_lock:
local = self.current_manifest.delta_since if self.current_manifest else None
if remote is not None and (local is None or remote > local):
self.last_error = "Manifest out of date"
raise RuntimeError("Manifest out of date")
async def publish_delta(self, delta_bytes: bytes, manifest_id: str) -> str:
if self.offline_mode or not self.relays:
return ""
await self.ensure_manifest_is_current()
await self._connect_async()
content = base64.b64encode(delta_bytes).decode("utf-8")
tag = nostr_client.Tag.event(nostr_client.EventId.parse(manifest_id))
builder = nostr_client.EventBuilder(
nostr_client.Kind(KIND_DELTA), content
).tags([tag])
event = builder.build(self.keys.public_key()).sign_with_keys(self.keys)
result = await self.client.send_event(event)
delta_id = result.id.to_hex() if hasattr(result, "id") else str(result)
created_at = getattr(
event, "created_at", getattr(event, "timestamp", int(time.time()))
)
if hasattr(created_at, "secs"):
created_at = created_at.secs
manifest_event = None
with self._state_lock:
if self.current_manifest is not None:
self.current_manifest.delta_since = int(created_at)
manifest_json = json.dumps(
{
"ver": self.current_manifest.ver,
"algo": self.current_manifest.algo,
"chunks": [
meta.__dict__ for meta in self.current_manifest.chunks
],
"delta_since": self.current_manifest.delta_since,
"nonce": self.current_manifest.nonce,
}
)
manifest_event = (
nostr_client.EventBuilder(
nostr_client.Kind(KIND_MANIFEST), manifest_json
)
.tags([nostr_client.Tag.identifier(self.current_manifest_id)])
.build(self.keys.public_key())
.sign_with_keys(self.keys)
)
self._delta_events.append(delta_id)
if manifest_event is not None:
await self.client.send_event(manifest_event)
return delta_id
async def fetch_deltas_since(self, version: int) -> list[bytes]:
if self.offline_mode or not self.relays:
return []
await self._connect_async()
pubkey = self.keys.public_key()
f = (
nostr_client.Filter()
.author(pubkey)
.kind(nostr_client.Kind(KIND_DELTA))
.since(nostr_client.Timestamp.from_secs(version))
)
timeout = timedelta(seconds=10)
events = (await self.client.fetch_events(f, timeout)).to_vec()
events.sort(
key=lambda ev: getattr(ev, "created_at", getattr(ev, "timestamp", 0))
)
deltas: list[bytes] = []
for ev in events:
deltas.append(base64.b64decode(ev.content().encode("utf-8")))
manifest = self.get_current_manifest()
if manifest is not None:
snap_size = sum(c.size for c in manifest.chunks)
if (
len(deltas) >= self.delta_threshold
or sum(len(d) for d in deltas) > snap_size
):
joined = b"".join(deltas)
await self.publish_snapshot(joined)
exp = nostr_client.Timestamp.from_secs(int(time.time()))
for ev in events:
exp_builder = nostr_client.EventBuilder(
nostr_client.Kind(KIND_DELTA), ev.content()
).tags([nostr_client.Tag.expiration(exp)])
exp_event = exp_builder.build(
self.keys.public_key()
).sign_with_keys(self.keys)
await self.client.send_event(exp_event)
return deltas
def get_current_manifest(self) -> Manifest | None:
with self._state_lock:
return self.current_manifest
def get_current_manifest_id(self) -> str | None:
with self._state_lock:
return self.current_manifest_id
def get_delta_events(self) -> list[str]:
with self._state_lock:
return list(self._delta_events)
-8
View File
@@ -1,8 +0,0 @@
# nostr/utils.py
import logging
# Example utility function (if any specific to nostr package)
def some_helper_function():
pass # Implement as needed
-96
View File
@@ -28,7 +28,6 @@ Generated on: 2025-04-06
├── encryption_manager.py
├── event_handler.py
├── key_manager.py
├── logging_config.py
├── utils.py
├── utils/
├── __init__.py
@@ -3082,98 +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
```python
# nostr/event_handler.py
import time
import logging
import traceback
from monstr.event.event import Event
# Instantiate the logger
logger = logging.getLogger(__name__)
class EventHandler:
"""
Handles incoming Nostr events.
"""
def __init__(self):
pass # Initialize if needed
def handle_new_event(self, evt: Event):
"""
Processes incoming events by logging their details.
:param evt: The received Event object.
"""
try:
# Assuming evt.created_at is always an integer Unix timestamp
if isinstance(evt.created_at, int):
created_at_str = time.strftime('%Y-%m-%d %H:%M:%S', time.localtime(evt.created_at))
else:
# Handle unexpected types gracefully
created_at_str = str(evt.created_at)
# Log the event details without extra newlines
logger.info(
f"[New Event] ID: {evt.id} | Created At: {created_at_str} | Content: {evt.content}"
)
except Exception as e:
logger.error(f"Error handling new event: {e}")
logger.error(traceback.format_exc())
# Optionally, handle the exception without re-raising
# For example, continue processing other events
```
## nostr/encryption_manager.py
```python
# nostr/encryption_manager.py
@@ -3323,7 +3230,6 @@ import fcntl
from .key_manager import KeyManager
from .encryption_manager import EncryptionManager
from .event_handler import EventHandler
from constants import APP_DIR
from utils.file_lock import lock_file
@@ -3373,7 +3279,6 @@ class NostrClient:
)
# Initialize event handler and client pool
self.event_handler = EventHandler()
self.relays = relays if relays else DEFAULT_RELAYS
self.client_pool = ClientPool(self.relays)
self.subscriptions = {}
@@ -3629,7 +3534,6 @@ class NostrClient:
try:
await asyncio.gather(
self.do_post_async(text),
self.subscribe_feed_async(self.event_handler.handle_new_event)
)
except Exception as e:
logger.error(f"An error occurred in publish_and_subscribe_async: {e}", exc_info=True)
+40 -36
View File
@@ -1,38 +1,42 @@
colorama>=0.4.6
termcolor>=1.1.0
cryptography>=40.0.2
bip-utils>=2.5.0
bech32==1.2.0
coincurve>=18.0.0
mnemonic
aiohttp>=3.12.14
bcrypt
pytest>=7.0
pytest-cov
pytest-xdist
portalocker>=2.8
nostr-sdk>=0.43
websocket-client==1.7.0
colorama>=0.4.6,<1
termcolor>=1.1.0,<4
cryptography>=40.0.2,<46
bip-utils>=2.5.0,<3
bech32>=1.2,<2
coincurve>=18.0.0,<22
mnemonic>=0.21,<1
aiohttp>=3.9,<4
bcrypt>=4,<5
pytest>=7,<9
pytest-cov>=4,<7
pytest-xdist>=3,<4
portalocker>=2.8,<4
nostr-sdk>=0.43,<1
websocket-client>=1.7,<2
websockets>=15.0.0
tomli
hypothesis
mutmut==2.4.4
pgpy==0.6.0
pyotp>=2.8.0
websockets>=15,<16
tomli>=2,<3
hypothesis>=6,<7
mutmut>=2.4.4,<4
pgpy>=0.6,<1
pyotp>=2.8,<3
freezegun
pyperclip
qrcode>=8.2
typer>=0.12.3
fastapi>=0.116.1
uvicorn>=0.35.0
starlette>=0.47.2
httpx>=0.28.1
requests>=2.32
python-multipart
orjson
argon2-cffi
toga-core>=0.5.2
pillow
toga-dummy>=0.5.2 # for headless GUI tests
freezegun>=1.5.4,<2
typer>=0.12.3,<1
# Optional dependencies - install as needed for additional features
pyperclip>=1.9,<2 # Clipboard support for secret mode
qrcode>=8.2,<9 # Generate QR codes for TOTP setup
fastapi>=0.110,<1 # API server
uvicorn>=0.29,<1 # API server
starlette>=0.47.2,<1 # API server
httpx>=0.28.1,<1 # API server
requests>=2.32,<3 # API server
python-multipart>=0.0.20,<0.1 # API server file uploads
PyJWT>=2.10.1,<3 # JWT authentication for API server
orjson>=3.11.1,<4 # Fast JSON serialization for API server
argon2-cffi>=21,<26 # Password hashing for API server
toga-core>=0.5.2,<1 # Desktop GUI
pillow>=11.3,<12 # Image support for GUI
toga-dummy>=0.5.2,<1 # Headless GUI tests
slowapi>=0.1.9,<1 # Rate limiting for API server
+32 -27
View File
@@ -1,30 +1,35 @@
# Runtime dependencies for vendoring/packaging only
# Generated from requirements.txt with all test-only packages removed
colorama>=0.4.6
termcolor>=1.1.0
cryptography>=40.0.2
bip-utils>=2.5.0
bech32==1.2.0
coincurve>=18.0.0
mnemonic
aiohttp>=3.12.14
bcrypt
portalocker>=2.8
nostr-sdk>=0.43
websocket-client==1.7.0
colorama>=0.4.6,<1
termcolor>=1.1.0,<4
cryptography>=40.0.2,<46
bip-utils>=2.5.0,<3
bech32>=1.2,<2
coincurve>=18.0.0,<22
mnemonic>=0.21,<1
aiohttp>=3.9,<4
bcrypt>=4,<5
portalocker>=2.8,<4
nostr-sdk>=0.43,<1
websocket-client>=1.7,<2
websockets>=15.0.0
tomli
pgpy==0.6.0
pyotp>=2.8.0
pyperclip
qrcode>=8.2
typer>=0.12.3
fastapi>=0.116.0
uvicorn>=0.35.0
httpx>=0.28.1
requests>=2.32
python-multipart
orjson
argon2-cffi
toga-core>=0.5.2
websockets>=15,<16
tomli>=2,<3
pgpy>=0.6,<1
pyotp>=2.8,<3
pyperclip>=1.9,<2
qrcode>=8.2,<9
typer>=0.12.3,<1
fastapi>=0.110,<1
uvicorn>=0.29,<1
starlette>=0.47.2,<1
httpx>=0.28.1,<1
requests>=2.32,<3
python-multipart>=0.0.20,<0.1
PyJWT>=2.10.1,<3
orjson>=3.11.1,<4
argon2-cffi>=21,<26
toga-core>=0.5.2,<1
pillow>=11.3,<12
toga-dummy>=0.5.2,<1
slowapi>=0.1.9,<1
-1
View File
@@ -3,4 +3,3 @@ selected_directories:
- utils/
- nostr/
- local_bip85/
- password_manager/
+3
View File
@@ -0,0 +1,3 @@
"""SeedPass package initialization."""
# Optionally re-export selected symbols here.
+350 -216
View File
@@ -7,81 +7,143 @@ import tempfile
from pathlib import Path
import secrets
import queue
import time
from collections import defaultdict, deque
from typing import Any, List, Optional
import hashlib
import logging
from fastapi import FastAPI, Header, HTTPException, Request, Response
import asyncio
import sys
from fastapi.middleware.cors import CORSMiddleware
import bcrypt
from seedpass.core.manager import PasswordManager
from seedpass.core.entry_types import EntryType
from seedpass.core.api import UtilityService
_RATE_LIMIT = int(os.getenv("SEEDPASS_RATE_LIMIT", "100"))
_RATE_WINDOW = int(os.getenv("SEEDPASS_RATE_WINDOW", "60"))
_RATE_LIMIT_STR = f"{_RATE_LIMIT}/{_RATE_WINDOW} seconds"
_MAX_IMPORT_BYTES = int(os.getenv("SEEDPASS_MAX_IMPORT_BYTES", str(10 * 1024 * 1024)))
app = FastAPI()
_pm: Optional[PasswordManager] = None
_token: str = ""
logger = logging.getLogger(__name__)
def _check_token(auth: str | None) -> None:
expected = f"Bearer {_token}"
if auth is None or not secrets.compare_digest(auth, expected):
@app.middleware("http")
async def dynamic_cors_headers(request: Request, call_next):
response = await call_next(request)
origins = {
o.strip() for o in os.getenv("SEEDPASS_CORS_ORIGINS", "").split(",") if o.strip()
}
request_origin = request.headers.get("origin")
if request_origin and request_origin in origins:
response.headers["access-control-allow-origin"] = request_origin
response.headers["vary"] = "Origin"
return response
def _get_pm(request: Request) -> PasswordManager:
pm = getattr(request.app.state, "pm", None)
if pm is None:
raise HTTPException(status_code=503, detail="Server not initialized")
return pm
def _check_token(request: Request, auth: str | None) -> None:
if auth is None or not auth.startswith("Bearer "):
raise HTTPException(status_code=401, detail="Unauthorized")
raw_token = auth.split(" ", 1)[1]
token = raw_token.encode()
token_hash = getattr(request.app.state, "token_hash", b"")
if not token_hash or not bcrypt.checkpw(token, token_hash):
raise HTTPException(status_code=401, detail="Unauthorized")
_enforce_rate_limit(request, raw_token)
def _reload_relays(relays: list[str]) -> None:
def _enforce_rate_limit(request: Request, raw_token: str) -> None:
now = time.monotonic()
client = request.client.host if request.client else "unknown"
token_tag = hashlib.blake2s(raw_token.encode("utf-8"), digest_size=8).hexdigest()
key = f"{client}:{token_tag}"
buckets = getattr(request.app.state, "rate_limit_buckets", None)
if buckets is None:
buckets = defaultdict(deque)
request.app.state.rate_limit_buckets = buckets
bucket = buckets[key]
while bucket and (now - bucket[0]) > _RATE_WINDOW:
bucket.popleft()
if len(bucket) >= _RATE_LIMIT:
raise HTTPException(
status_code=429,
detail=f"Rate limit exceeded ({_RATE_LIMIT_STR})",
)
bucket.append(now)
def _reload_relays(request: Request, relays: list[str]) -> None:
"""Reload the Nostr client with a new relay list."""
assert _pm is not None
pm = _get_pm(request)
try:
_pm.nostr_client.close_client_pool()
except Exception:
pass
pm.nostr_client.close_client_pool()
except (OSError, RuntimeError, ValueError) as exc:
logger.warning("Failed to close NostrClient pool: %s", exc)
try:
_pm.nostr_client.relays = relays
_pm.nostr_client.initialize_client_pool()
except Exception:
pass
pm.nostr_client.relays = relays
pm.nostr_client.initialize_client_pool()
except (OSError, RuntimeError, ValueError) as exc:
logger.error("Failed to initialize NostrClient with relays %s: %s", relays, exc)
def start_server(fingerprint: str | None = None, token: str | None = None) -> str:
"""Initialize global state and return the API token.
def start_server(fingerprint: str | None = None) -> str:
"""Initialize global state and return a random API token.
Parameters
----------
fingerprint:
Optional seed profile fingerprint to select before starting the server.
token:
Optional API token to use. If omitted, it checks the
``SEEDPASS_API_TOKEN`` environment variable, or generates a new one.
"""
global _pm, _token
if fingerprint is None:
_pm = PasswordManager()
pm = PasswordManager()
else:
_pm = PasswordManager(fingerprint=fingerprint)
pm = PasswordManager(fingerprint=fingerprint)
app.state.pm = pm
raw_token = secrets.token_urlsafe(32)
app.state.token_hash = bcrypt.hashpw(raw_token.encode(), bcrypt.gensalt())
app.state.rate_limit_buckets = defaultdict(deque)
return raw_token
_token = token or os.getenv("SEEDPASS_API_TOKEN") or secrets.token_urlsafe(16)
origins = [
o.strip()
for o in os.getenv("SEEDPASS_CORS_ORIGINS", "").split(",")
if o.strip()
]
if origins and app.middleware_stack is None:
app.add_middleware(
CORSMiddleware,
allow_origins=origins,
allow_methods=["*"],
allow_headers=["*"],
)
return _token
def _require_password(request: Request, password: str | None) -> None:
pm = _get_pm(request)
if password is None or not pm.verify_password(password):
raise HTTPException(status_code=401, detail="Invalid password")
def _validate_encryption_path(request: Request, path: Path) -> Path:
"""Validate and normalize ``path`` within the active fingerprint directory.
Returns the resolved absolute path if validation succeeds.
"""
pm = _get_pm(request)
try:
return pm.encryption_manager.resolve_relative_path(path)
except ValueError as e:
raise HTTPException(status_code=400, detail=str(e))
@app.get("/api/v1/entry")
def search_entry(query: str, authorization: str | None = Header(None)) -> List[Any]:
_check_token(authorization)
assert _pm is not None
results = _pm.entry_manager.search_entries(query)
async def search_entry(
request: Request, query: str, authorization: str | None = Header(None)
) -> List[Any]:
_check_token(request, authorization)
pm = _get_pm(request)
results = pm.entry_manager.search_entries(query)
return [
{
"id": idx,
@@ -96,17 +158,24 @@ def search_entry(query: str, authorization: str | None = Header(None)) -> List[A
@app.get("/api/v1/entry/{entry_id}")
def get_entry(entry_id: int, authorization: str | None = Header(None)) -> Any:
_check_token(authorization)
assert _pm is not None
entry = _pm.entry_manager.retrieve_entry(entry_id)
async def get_entry(
request: Request,
entry_id: int,
authorization: str | None = Header(None),
password: str | None = Header(None, alias="X-SeedPass-Password"),
) -> Any:
_check_token(request, authorization)
_require_password(request, password)
pm = _get_pm(request)
entry = pm.entry_manager.retrieve_entry(entry_id)
if entry is None:
raise HTTPException(status_code=404, detail="Not found")
return entry
@app.post("/api/v1/entry")
def create_entry(
async def create_entry(
request: Request,
entry: dict,
authorization: str | None = Header(None),
) -> dict[str, Any]:
@@ -116,8 +185,8 @@ def create_entry(
on, the corresponding entry type is created. When omitted or set to
``password`` the behaviour matches the legacy password-entry API.
"""
_check_token(authorization)
assert _pm is not None
_check_token(request, authorization)
pm = _get_pm(request)
etype = (entry.get("type") or entry.get("kind") or "password").lower()
@@ -133,7 +202,8 @@ def create_entry(
"min_special",
]
kwargs = {k: entry.get(k) for k in policy_keys if entry.get(k) is not None}
index = _pm.entry_manager.add_entry(
index = pm.entry_manager.add_entry(
entry.get("label"),
int(entry.get("length", 12)),
entry.get("username"),
@@ -143,23 +213,25 @@ def create_entry(
return {"id": index}
if etype == "totp":
index = _pm.entry_manager.get_next_index()
uri = _pm.entry_manager.add_totp(
index = pm.entry_manager.get_next_index()
uri = pm.entry_manager.add_totp(
entry.get("label"),
_pm.parent_seed,
pm.KEY_TOTP_DET if entry.get("deterministic", False) else None,
secret=entry.get("secret"),
index=entry.get("index"),
period=int(entry.get("period", 30)),
digits=int(entry.get("digits", 6)),
notes=entry.get("notes", ""),
archived=entry.get("archived", False),
deterministic=entry.get("deterministic", False),
)
return {"id": index, "uri": uri}
if etype == "ssh":
index = _pm.entry_manager.add_ssh_key(
index = pm.entry_manager.add_ssh_key(
entry.get("label"),
_pm.parent_seed,
pm.parent_seed,
index=entry.get("index"),
notes=entry.get("notes", ""),
archived=entry.get("archived", False),
@@ -167,9 +239,9 @@ def create_entry(
return {"id": index}
if etype == "pgp":
index = _pm.entry_manager.add_pgp_key(
index = pm.entry_manager.add_pgp_key(
entry.get("label"),
_pm.parent_seed,
pm.parent_seed,
index=entry.get("index"),
key_type=entry.get("key_type", "ed25519"),
user_id=entry.get("user_id", ""),
@@ -179,9 +251,9 @@ def create_entry(
return {"id": index}
if etype == "nostr":
index = _pm.entry_manager.add_nostr_key(
index = pm.entry_manager.add_nostr_key(
entry.get("label"),
_pm.parent_seed,
pm.parent_seed,
index=entry.get("index"),
notes=entry.get("notes", ""),
archived=entry.get("archived", False),
@@ -189,7 +261,7 @@ def create_entry(
return {"id": index}
if etype == "key_value":
index = _pm.entry_manager.add_key_value(
index = pm.entry_manager.add_key_value(
entry.get("label"),
entry.get("key"),
entry.get("value"),
@@ -199,13 +271,13 @@ def create_entry(
if etype in {"seed", "managed_account"}:
func = (
_pm.entry_manager.add_seed
pm.entry_manager.add_seed
if etype == "seed"
else _pm.entry_manager.add_managed_account
else pm.entry_manager.add_managed_account
)
index = func(
entry.get("label"),
_pm.parent_seed,
pm.parent_seed,
index=entry.get("index"),
notes=entry.get("notes", ""),
)
@@ -216,6 +288,7 @@ def create_entry(
@app.put("/api/v1/entry/{entry_id}")
def update_entry(
request: Request,
entry_id: int,
entry: dict,
authorization: str | None = Header(None),
@@ -225,10 +298,10 @@ def update_entry(
Additional fields like ``period``, ``digits`` and ``value`` are forwarded for
specialized entry types (e.g. TOTP or key/value entries).
"""
_check_token(authorization)
assert _pm is not None
_check_token(request, authorization)
pm = _get_pm(request)
try:
_pm.entry_manager.modify_entry(
pm.entry_manager.modify_entry(
entry_id,
username=entry.get("username"),
url=entry.get("url"),
@@ -246,31 +319,34 @@ def update_entry(
@app.post("/api/v1/entry/{entry_id}/archive")
def archive_entry(
entry_id: int, authorization: str | None = Header(None)
request: Request, entry_id: int, authorization: str | None = Header(None)
) -> dict[str, str]:
"""Archive an entry."""
_check_token(authorization)
assert _pm is not None
_pm.entry_manager.archive_entry(entry_id)
_check_token(request, authorization)
pm = _get_pm(request)
pm.entry_manager.archive_entry(entry_id)
return {"status": "archived"}
@app.post("/api/v1/entry/{entry_id}/unarchive")
def unarchive_entry(
entry_id: int, authorization: str | None = Header(None)
request: Request, entry_id: int, authorization: str | None = Header(None)
) -> dict[str, str]:
"""Restore an archived entry."""
_check_token(authorization)
assert _pm is not None
_pm.entry_manager.restore_entry(entry_id)
_check_token(request, authorization)
pm = _get_pm(request)
pm.entry_manager.restore_entry(entry_id)
return {"status": "active"}
@app.get("/api/v1/config/{key}")
def get_config(key: str, authorization: str | None = Header(None)) -> Any:
_check_token(authorization)
assert _pm is not None
value = _pm.config_manager.load_config(require_pin=False).get(key)
def get_config(
request: Request, key: str, authorization: str | None = Header(None)
) -> Any:
_check_token(request, authorization)
pm = _get_pm(request)
value = pm.config_manager.load_config(require_pin=False).get(key)
if value is None:
raise HTTPException(status_code=404, detail="Not found")
return {"key": key, "value": value}
@@ -278,12 +354,15 @@ def get_config(key: str, authorization: str | None = Header(None)) -> Any:
@app.put("/api/v1/config/{key}")
def update_config(
key: str, data: dict, authorization: str | None = Header(None)
request: Request,
key: str,
data: dict,
authorization: str | None = Header(None),
) -> dict[str, str]:
"""Update a configuration setting."""
_check_token(authorization)
assert _pm is not None
cfg = _pm.config_manager
_check_token(request, authorization)
pm = _get_pm(request)
cfg = pm.config_manager
mapping = {
"relays": lambda v: cfg.set_relays(v, require_pin=False),
"pin": cfg.set_pin,
@@ -296,6 +375,7 @@ def update_config(
}
action = mapping.get(key)
if action is None:
raise HTTPException(status_code=400, detail="Unknown key")
@@ -308,84 +388,105 @@ def update_config(
@app.post("/api/v1/secret-mode")
def set_secret_mode(
data: dict, authorization: str | None = Header(None)
request: Request, data: dict, authorization: str | None = Header(None)
) -> dict[str, str]:
"""Enable/disable secret mode and set the clipboard delay."""
_check_token(authorization)
assert _pm is not None
_check_token(request, authorization)
pm = _get_pm(request)
enabled = data.get("enabled")
delay = data.get("delay")
if enabled is None or delay is None:
raise HTTPException(status_code=400, detail="Missing fields")
cfg = _pm.config_manager
cfg = pm.config_manager
cfg.set_secret_mode_enabled(bool(enabled))
cfg.set_clipboard_clear_delay(int(delay))
_pm.secret_mode_enabled = bool(enabled)
_pm.clipboard_clear_delay = int(delay)
pm.secret_mode_enabled = bool(enabled)
pm.clipboard_clear_delay = int(delay)
return {"status": "ok"}
@app.get("/api/v1/fingerprint")
def list_fingerprints(authorization: str | None = Header(None)) -> List[str]:
_check_token(authorization)
assert _pm is not None
return _pm.fingerprint_manager.list_fingerprints()
def list_fingerprints(
request: Request, authorization: str | None = Header(None)
) -> List[str]:
_check_token(request, authorization)
pm = _get_pm(request)
return pm.fingerprint_manager.list_fingerprints()
@app.post("/api/v1/fingerprint")
def add_fingerprint(authorization: str | None = Header(None)) -> dict[str, str]:
def add_fingerprint(
request: Request, authorization: str | None = Header(None)
) -> dict[str, str]:
"""Create a new seed profile."""
_check_token(authorization)
assert _pm is not None
_pm.add_new_fingerprint()
_check_token(request, authorization)
pm = _get_pm(request)
pm.add_new_fingerprint()
return {"status": "ok"}
@app.delete("/api/v1/fingerprint/{fingerprint}")
def remove_fingerprint(
fingerprint: str, authorization: str | None = Header(None)
request: Request, fingerprint: str, authorization: str | None = Header(None)
) -> dict[str, str]:
"""Remove a seed profile."""
_check_token(authorization)
assert _pm is not None
_pm.fingerprint_manager.remove_fingerprint(fingerprint)
_check_token(request, authorization)
pm = _get_pm(request)
pm.fingerprint_manager.remove_fingerprint(fingerprint)
return {"status": "deleted"}
@app.post("/api/v1/fingerprint/select")
def select_fingerprint(
data: dict, authorization: str | None = Header(None)
request: Request, data: dict, authorization: str | None = Header(None)
) -> dict[str, str]:
"""Switch the active seed profile."""
_check_token(authorization)
assert _pm is not None
_check_token(request, authorization)
pm = _get_pm(request)
fp = data.get("fingerprint")
if not fp:
raise HTTPException(status_code=400, detail="Missing fingerprint")
_pm.select_fingerprint(fp)
pm.select_fingerprint(fp)
return {"status": "ok"}
@app.get("/api/v1/totp/export")
def export_totp(authorization: str | None = Header(None)) -> dict:
def export_totp(
request: Request,
authorization: str | None = Header(None),
password: str | None = Header(None, alias="X-SeedPass-Password"),
) -> dict:
"""Return all stored TOTP entries in JSON format."""
_check_token(authorization)
assert _pm is not None
return _pm.entry_manager.export_totp_entries(_pm.parent_seed)
_check_token(request, authorization)
_require_password(request, password)
pm = _get_pm(request)
key = getattr(pm, "KEY_TOTP_DET", None) or getattr(pm, "parent_seed", None)
return pm.entry_manager.export_totp_entries(key)
@app.get("/api/v1/totp")
def get_totp_codes(authorization: str | None = Header(None)) -> dict:
def get_totp_codes(
request: Request,
authorization: str | None = Header(None),
password: str | None = Header(None, alias="X-SeedPass-Password"),
) -> dict:
"""Return active TOTP codes with remaining seconds."""
_check_token(authorization)
assert _pm is not None
entries = _pm.entry_manager.list_entries(
filter_kind=EntryType.TOTP.value, include_archived=False
_check_token(request, authorization)
_require_password(request, password)
pm = _get_pm(request)
entries = pm.entry_manager.list_entries(
filter_kinds=[EntryType.TOTP.value], include_archived=False
)
codes = []
for idx, label, _u, _url, _arch in entries:
code = _pm.entry_manager.get_totp_code(idx, _pm.parent_seed)
rem = _pm.entry_manager.get_totp_time_remaining(idx)
key = getattr(pm, "KEY_TOTP_DET", None) or getattr(pm, "parent_seed", None)
code = pm.entry_manager.get_totp_code(idx, key)
rem = pm.entry_manager.get_totp_time_remaining(idx)
codes.append(
{"id": idx, "label": label, "code": code, "seconds_remaining": rem}
)
@@ -393,132 +494,138 @@ def get_totp_codes(authorization: str | None = Header(None)) -> dict:
@app.get("/api/v1/stats")
def get_profile_stats(authorization: str | None = Header(None)) -> dict:
def get_profile_stats(
request: Request, authorization: str | None = Header(None)
) -> dict:
"""Return statistics about the active seed profile."""
_check_token(authorization)
assert _pm is not None
return _pm.get_profile_stats()
_check_token(request, authorization)
pm = _get_pm(request)
return pm.get_profile_stats()
@app.get("/api/v1/notifications")
def get_notifications(authorization: str | None = Header(None)) -> List[dict]:
def get_notifications(
request: Request, authorization: str | None = Header(None)
) -> List[dict]:
"""Return and clear queued notifications."""
_check_token(authorization)
assert _pm is not None
_check_token(request, authorization)
pm = _get_pm(request)
notes = []
while True:
try:
note = _pm.notifications.get_nowait()
note = pm.notifications.get_nowait()
except queue.Empty:
break
notes.append({"level": note.level, "message": note.message})
return notes
@app.get("/api/v1/parent-seed")
def get_parent_seed(
authorization: str | None = Header(None), file: str | None = None
) -> dict:
"""Return the parent seed or save it as an encrypted backup."""
_check_token(authorization)
assert _pm is not None
if file:
path = Path(file)
_pm.encryption_manager.encrypt_and_save_file(
_pm.parent_seed.encode("utf-8"), path
)
return {"status": "saved", "path": str(path)}
return {"seed": _pm.parent_seed}
@app.get("/api/v1/nostr/pubkey")
def get_nostr_pubkey(authorization: str | None = Header(None)) -> Any:
_check_token(authorization)
assert _pm is not None
return {"npub": _pm.nostr_client.key_manager.get_npub()}
def get_nostr_pubkey(request: Request, authorization: str | None = Header(None)) -> Any:
_check_token(request, authorization)
pm = _get_pm(request)
return {"npub": pm.nostr_client.key_manager.get_npub()}
@app.get("/api/v1/relays")
def list_relays(authorization: str | None = Header(None)) -> dict:
def list_relays(request: Request, authorization: str | None = Header(None)) -> dict:
"""Return the configured Nostr relays."""
_check_token(authorization)
assert _pm is not None
cfg = _pm.config_manager.load_config(require_pin=False)
_check_token(request, authorization)
pm = _get_pm(request)
cfg = pm.config_manager.load_config(require_pin=False)
return {"relays": cfg.get("relays", [])}
@app.post("/api/v1/relays")
def add_relay(data: dict, authorization: str | None = Header(None)) -> dict[str, str]:
def add_relay(
request: Request, data: dict, authorization: str | None = Header(None)
) -> dict[str, str]:
"""Add a relay URL to the configuration."""
_check_token(authorization)
assert _pm is not None
_check_token(request, authorization)
pm = _get_pm(request)
url = data.get("url")
if not url:
raise HTTPException(status_code=400, detail="Missing url")
cfg = _pm.config_manager.load_config(require_pin=False)
cfg = pm.config_manager.load_config(require_pin=False)
relays = cfg.get("relays", [])
if url in relays:
raise HTTPException(status_code=400, detail="Relay already present")
relays.append(url)
_pm.config_manager.set_relays(relays, require_pin=False)
_reload_relays(relays)
pm.config_manager.set_relays(relays, require_pin=False)
_reload_relays(request, relays)
return {"status": "ok"}
@app.delete("/api/v1/relays/{idx}")
def remove_relay(idx: int, authorization: str | None = Header(None)) -> dict[str, str]:
def remove_relay(
request: Request, idx: int, authorization: str | None = Header(None)
) -> dict[str, str]:
"""Remove a relay by its index (1-based)."""
_check_token(authorization)
assert _pm is not None
cfg = _pm.config_manager.load_config(require_pin=False)
_check_token(request, authorization)
pm = _get_pm(request)
cfg = pm.config_manager.load_config(require_pin=False)
relays = cfg.get("relays", [])
if not (1 <= idx <= len(relays)):
raise HTTPException(status_code=400, detail="Invalid index")
if len(relays) == 1:
raise HTTPException(status_code=400, detail="At least one relay required")
relays.pop(idx - 1)
_pm.config_manager.set_relays(relays, require_pin=False)
_reload_relays(relays)
pm.config_manager.set_relays(relays, require_pin=False)
_reload_relays(request, relays)
return {"status": "ok"}
@app.post("/api/v1/relays/reset")
def reset_relays(authorization: str | None = Header(None)) -> dict[str, str]:
def reset_relays(
request: Request, authorization: str | None = Header(None)
) -> dict[str, str]:
"""Reset relay list to defaults."""
_check_token(authorization)
assert _pm is not None
_check_token(request, authorization)
pm = _get_pm(request)
from nostr.client import DEFAULT_RELAYS
relays = list(DEFAULT_RELAYS)
_pm.config_manager.set_relays(relays, require_pin=False)
_reload_relays(relays)
pm.config_manager.set_relays(relays, require_pin=False)
_reload_relays(request, relays)
return {"status": "ok"}
@app.post("/api/v1/checksum/verify")
def verify_checksum(authorization: str | None = Header(None)) -> dict[str, str]:
def verify_checksum(
request: Request, authorization: str | None = Header(None)
) -> dict[str, str]:
"""Verify the SeedPass script checksum."""
_check_token(authorization)
assert _pm is not None
_pm.handle_verify_checksum()
_check_token(request, authorization)
pm = _get_pm(request)
pm.handle_verify_checksum()
return {"status": "ok"}
@app.post("/api/v1/checksum/update")
def update_checksum(authorization: str | None = Header(None)) -> dict[str, str]:
def update_checksum(
request: Request, authorization: str | None = Header(None)
) -> dict[str, str]:
"""Regenerate the script checksum file."""
_check_token(authorization)
assert _pm is not None
_pm.handle_update_script_checksum()
_check_token(request, authorization)
pm = _get_pm(request)
pm.handle_update_script_checksum()
return {"status": "ok"}
@app.post("/api/v1/vault/export")
def export_vault(authorization: str | None = Header(None)):
def export_vault(
request: Request,
authorization: str | None = Header(None),
password: str | None = Header(None, alias="X-SeedPass-Password"),
):
"""Export the vault and return the encrypted file."""
_check_token(authorization)
assert _pm is not None
path = _pm.handle_export_database()
_check_token(request, authorization)
_require_password(request, password)
pm = _get_pm(request)
path = pm.handle_export_database()
if path is None:
raise HTTPException(status_code=500, detail="Export failed")
data = Path(path).read_bytes()
@@ -530,75 +637,96 @@ async def import_vault(
request: Request, authorization: str | None = Header(None)
) -> dict[str, str]:
"""Import a vault backup from a file upload or a server path."""
_check_token(authorization)
assert _pm is not None
_check_token(request, authorization)
pm = _get_pm(request)
ctype = request.headers.get("content-type", "")
if ctype.startswith("multipart/form-data"):
form = await request.form()
file = form.get("file")
if file is None:
raise HTTPException(status_code=400, detail="Missing file")
data = await file.read()
with tempfile.NamedTemporaryFile(delete=False, suffix=".json.enc") as tmp:
data = await file.read(_MAX_IMPORT_BYTES + 1)
if len(data) > _MAX_IMPORT_BYTES:
raise HTTPException(
status_code=413,
detail=f"Uploaded file exceeds max size of {_MAX_IMPORT_BYTES} bytes",
)
with tempfile.NamedTemporaryFile(delete=False) as tmp:
tmp.write(data)
tmp_path = Path(tmp.name)
try:
_pm.handle_import_database(tmp_path)
pm.handle_import_database(tmp_path)
finally:
os.unlink(tmp_path)
else:
body = await request.json()
path = body.get("path")
if not path:
path_str = body.get("path")
if not path_str:
raise HTTPException(status_code=400, detail="Missing file or path")
p = Path(path)
if p.is_absolute() or ".." in p.parts:
raise HTTPException(status_code=400, detail="Invalid path")
full_path = _pm.fingerprint_dir / p
_pm.handle_import_database(full_path)
_pm.sync_vault()
path = _validate_encryption_path(request, Path(path_str))
if not str(path).endswith(".json.enc"):
raise HTTPException(
status_code=400,
detail="Selected file must be a '.json.enc' backup",
)
pm.handle_import_database(path)
pm.sync_vault()
return {"status": "ok"}
@app.post("/api/v1/vault/backup-parent-seed")
def backup_parent_seed(
data: dict | None = None, authorization: str | None = Header(None)
request: Request,
data: dict,
authorization: str | None = Header(None),
password: str | None = Header(None, alias="X-SeedPass-Password"),
) -> dict[str, str]:
"""Backup and reveal the parent seed."""
_check_token(authorization)
assert _pm is not None
path = None
if data is not None:
p = data.get("path")
if p:
# Prevent directory traversal
if "/" in p or "\\" in p or ".." in p:
raise HTTPException(status_code=400, detail="Invalid filename")
path = Path(p)
_pm.handle_backup_reveal_parent_seed(path)
return {"status": "ok"}
"""Create an encrypted backup of the parent seed after confirmation."""
_check_token(request, authorization)
_require_password(request, password)
pm = _get_pm(request)
if not data.get("confirm"):
raise HTTPException(status_code=400, detail="Confirmation required")
path_str = data.get("path")
if not path_str:
raise HTTPException(status_code=400, detail="Missing path")
path = Path(path_str)
_validate_encryption_path(request, path)
pm.encryption_manager.encrypt_and_save_file(pm.parent_seed.encode("utf-8"), path)
return {"status": "saved", "path": str(path)}
@app.post("/api/v1/change-password")
def change_password(
data: dict, authorization: str | None = Header(None)
request: Request, data: dict, authorization: str | None = Header(None)
) -> dict[str, str]:
"""Change the master password for the active profile."""
_check_token(authorization)
assert _pm is not None
_pm.change_password(data.get("old", ""), data.get("new", ""))
_check_token(request, authorization)
pm = _get_pm(request)
pm.change_password(data.get("old", ""), data.get("new", ""))
return {"status": "ok"}
@app.post("/api/v1/password")
def generate_password(
data: dict, authorization: str | None = Header(None)
request: Request, data: dict, authorization: str | None = Header(None)
) -> dict[str, str]:
"""Generate a password using optional policy overrides."""
_check_token(authorization)
assert _pm is not None
_check_token(request, authorization)
pm = _get_pm(request)
length = int(data.get("length", 12))
policy_keys = [
"include_special_chars",
"allowed_special_chars",
@@ -610,22 +738,28 @@ def generate_password(
"min_special",
]
kwargs = {k: data.get(k) for k in policy_keys if data.get(k) is not None}
util = UtilityService(_pm)
util = UtilityService(pm)
password = util.generate_password(length, **kwargs)
return {"password": password}
@app.post("/api/v1/vault/lock")
def lock_vault(authorization: str | None = Header(None)) -> dict[str, str]:
def lock_vault(
request: Request, authorization: str | None = Header(None)
) -> dict[str, str]:
"""Lock the vault and clear sensitive data from memory."""
_check_token(authorization)
assert _pm is not None
_pm.lock_vault()
_check_token(request, authorization)
pm = _get_pm(request)
pm.lock_vault()
return {"status": "locked"}
@app.post("/api/v1/shutdown")
async def shutdown_server(authorization: str | None = Header(None)) -> dict[str, str]:
_check_token(authorization)
async def shutdown_server(
request: Request, authorization: str | None = Header(None)
) -> dict[str, str]:
_check_token(request, authorization)
asyncio.get_event_loop().call_soon(sys.exit, 0)
return {"status": "shutting down"}
-886
View File
@@ -1,886 +0,0 @@
from pathlib import Path
from typing import Optional, List
import json
import os
import typer
import sys
from seedpass.core.manager import PasswordManager
from seedpass.core.entry_types import EntryType
from seedpass.core.api import (
VaultService,
ProfileService,
SyncService,
EntryService,
ConfigService,
UtilityService,
NostrService,
ChangePasswordRequest,
UnlockRequest,
BackupParentSeedRequest,
ProfileSwitchRequest,
ProfileRemoveRequest,
)
import uvicorn
from . import api as api_module
import importlib
import importlib.util
import subprocess
app = typer.Typer(
help="SeedPass command line interface",
invoke_without_command=True,
)
# Global option shared across all commands
fingerprint_option = typer.Option(
None,
"--fingerprint",
"-f",
help="Specify which seed profile to use",
)
# Sub command groups
entry_app = typer.Typer(help="Manage individual entries")
vault_app = typer.Typer(help="Manage the entire vault")
nostr_app = typer.Typer(help="Interact with Nostr relays")
config_app = typer.Typer(help="Get or set configuration values")
fingerprint_app = typer.Typer(help="Manage seed profiles")
util_app = typer.Typer(help="Utility commands")
api_app = typer.Typer(help="Run the API server")
app.add_typer(entry_app, name="entry")
app.add_typer(vault_app, name="vault")
app.add_typer(nostr_app, name="nostr")
app.add_typer(config_app, name="config")
app.add_typer(fingerprint_app, name="fingerprint")
app.add_typer(util_app, name="util")
app.add_typer(api_app, name="api")
def _get_pm(ctx: typer.Context) -> PasswordManager:
"""Return a PasswordManager optionally selecting a fingerprint."""
fp = ctx.obj.get("fingerprint")
if fp is None:
pm = PasswordManager()
else:
pm = PasswordManager(fingerprint=fp)
return pm
def _get_services(
ctx: typer.Context,
) -> tuple[VaultService, ProfileService, SyncService]:
"""Return service layer instances for the current context."""
pm = _get_pm(ctx)
return VaultService(pm), ProfileService(pm), SyncService(pm)
def _get_entry_service(ctx: typer.Context) -> EntryService:
pm = _get_pm(ctx)
return EntryService(pm)
def _get_config_service(ctx: typer.Context) -> ConfigService:
pm = _get_pm(ctx)
return ConfigService(pm)
def _get_util_service(ctx: typer.Context) -> UtilityService:
pm = _get_pm(ctx)
return UtilityService(pm)
def _get_nostr_service(ctx: typer.Context) -> NostrService:
pm = _get_pm(ctx)
return NostrService(pm)
def _gui_backend_available() -> bool:
"""Return True if a platform-specific BeeWare backend is installed."""
for pkg in ("toga_gtk", "toga_winforms", "toga_cocoa"):
if importlib.util.find_spec(pkg) is not None:
return True
return False
@app.callback(invoke_without_command=True)
def main(ctx: typer.Context, fingerprint: Optional[str] = fingerprint_option) -> None:
"""SeedPass CLI entry point.
When called without a subcommand this launches the interactive TUI.
"""
ctx.obj = {"fingerprint": fingerprint}
if ctx.invoked_subcommand is None:
tui = importlib.import_module("main")
raise typer.Exit(tui.main(fingerprint=fingerprint))
@entry_app.command("list")
def entry_list(
ctx: typer.Context,
sort: str = typer.Option(
"index", "--sort", help="Sort by 'index', 'label', or 'updated'"
),
kind: Optional[str] = typer.Option(None, "--kind", help="Filter by entry type"),
archived: bool = typer.Option(False, "--archived", help="Include archived"),
) -> None:
"""List entries in the vault."""
service = _get_entry_service(ctx)
entries = service.list_entries(
sort_by=sort, filter_kind=kind, include_archived=archived
)
for idx, label, username, url, is_archived in entries:
line = f"{idx}: {label}"
if username:
line += f" ({username})"
if url:
line += f" {url}"
if is_archived:
line += " [archived]"
typer.echo(line)
@entry_app.command("search")
def entry_search(
ctx: typer.Context,
query: str,
kind: List[str] = typer.Option(
None,
"--kind",
"-k",
help="Filter by entry kinds (can be repeated)",
),
) -> None:
"""Search entries."""
service = _get_entry_service(ctx)
kinds = list(kind) if kind else None
results = service.search_entries(query, kinds=kinds)
if not results:
typer.echo("No matching entries found")
return
for idx, label, username, url, _arch, etype in results:
line = f"{idx}: {etype.value.replace('_', ' ').title()} - {label}"
if username:
line += f" ({username})"
if url:
line += f" {url}"
typer.echo(line)
@entry_app.command("get")
def entry_get(ctx: typer.Context, query: str) -> None:
"""Retrieve a single entry's secret."""
service = _get_entry_service(ctx)
matches = service.search_entries(query)
if len(matches) == 0:
typer.echo("No matching entries found")
raise typer.Exit(code=1)
if len(matches) > 1:
typer.echo("Matches:")
for idx, label, username, _url, _arch, etype in matches:
name = f"{idx}: {etype.value.replace('_', ' ').title()} - {label}"
if username:
name += f" ({username})"
typer.echo(name)
raise typer.Exit(code=1)
index = matches[0][0]
entry = service.retrieve_entry(index)
etype = entry.get("type", entry.get("kind"))
if etype == EntryType.PASSWORD.value:
length = int(entry.get("length", 12))
password = service.generate_password(length, index)
typer.echo(password)
elif etype == EntryType.TOTP.value:
code = service.get_totp_code(index)
typer.echo(code)
else:
typer.echo("Unsupported entry type")
raise typer.Exit(code=1)
@entry_app.command("add")
def entry_add(
ctx: typer.Context,
label: str,
length: int = typer.Option(12, "--length"),
username: Optional[str] = typer.Option(None, "--username"),
url: Optional[str] = typer.Option(None, "--url"),
no_special: bool = typer.Option(
False, "--no-special", help="Exclude special characters", is_flag=True
),
allowed_special_chars: Optional[str] = typer.Option(
None, "--allowed-special-chars", help="Explicit set of special characters"
),
special_mode: Optional[str] = typer.Option(
None,
"--special-mode",
help="Special character mode",
),
exclude_ambiguous: bool = typer.Option(
False,
"--exclude-ambiguous",
help="Exclude ambiguous characters",
is_flag=True,
),
min_uppercase: Optional[int] = typer.Option(None, "--min-uppercase"),
min_lowercase: Optional[int] = typer.Option(None, "--min-lowercase"),
min_digits: Optional[int] = typer.Option(None, "--min-digits"),
min_special: Optional[int] = typer.Option(None, "--min-special"),
) -> None:
"""Add a new password entry and output its index."""
service = _get_entry_service(ctx)
kwargs = {}
if no_special:
kwargs["include_special_chars"] = False
if allowed_special_chars is not None:
kwargs["allowed_special_chars"] = allowed_special_chars
if special_mode is not None:
kwargs["special_mode"] = special_mode
if exclude_ambiguous:
kwargs["exclude_ambiguous"] = True
if min_uppercase is not None:
kwargs["min_uppercase"] = min_uppercase
if min_lowercase is not None:
kwargs["min_lowercase"] = min_lowercase
if min_digits is not None:
kwargs["min_digits"] = min_digits
if min_special is not None:
kwargs["min_special"] = min_special
index = service.add_entry(label, length, username, url, **kwargs)
typer.echo(str(index))
@entry_app.command("add-totp")
def entry_add_totp(
ctx: typer.Context,
label: str,
index: Optional[int] = typer.Option(None, "--index", help="Derivation index"),
secret: Optional[str] = typer.Option(None, "--secret", help="Import secret"),
period: int = typer.Option(30, "--period", help="TOTP period in seconds"),
digits: int = typer.Option(6, "--digits", help="Number of TOTP digits"),
) -> None:
"""Add a TOTP entry and output the otpauth URI."""
service = _get_entry_service(ctx)
uri = service.add_totp(
label,
index=index,
secret=secret,
period=period,
digits=digits,
)
typer.echo(uri)
@entry_app.command("add-ssh")
def entry_add_ssh(
ctx: typer.Context,
label: str,
index: Optional[int] = typer.Option(None, "--index", help="Derivation index"),
notes: str = typer.Option("", "--notes", help="Entry notes"),
) -> None:
"""Add an SSH key entry and output its index."""
service = _get_entry_service(ctx)
idx = service.add_ssh_key(
label,
index=index,
notes=notes,
)
typer.echo(str(idx))
@entry_app.command("add-pgp")
def entry_add_pgp(
ctx: typer.Context,
label: str,
index: Optional[int] = typer.Option(None, "--index", help="Derivation index"),
key_type: str = typer.Option("ed25519", "--key-type", help="Key type"),
user_id: str = typer.Option("", "--user-id", help="User ID"),
notes: str = typer.Option("", "--notes", help="Entry notes"),
) -> None:
"""Add a PGP key entry and output its index."""
service = _get_entry_service(ctx)
idx = service.add_pgp_key(
label,
index=index,
key_type=key_type,
user_id=user_id,
notes=notes,
)
typer.echo(str(idx))
@entry_app.command("add-nostr")
def entry_add_nostr(
ctx: typer.Context,
label: str,
index: Optional[int] = typer.Option(None, "--index", help="Derivation index"),
notes: str = typer.Option("", "--notes", help="Entry notes"),
) -> None:
"""Add a Nostr key entry and output its index."""
service = _get_entry_service(ctx)
idx = service.add_nostr_key(
label,
index=index,
notes=notes,
)
typer.echo(str(idx))
@entry_app.command("add-seed")
def entry_add_seed(
ctx: typer.Context,
label: str,
index: Optional[int] = typer.Option(None, "--index", help="Derivation index"),
words: int = typer.Option(24, "--words", help="Word count"),
notes: str = typer.Option("", "--notes", help="Entry notes"),
) -> None:
"""Add a derived seed phrase entry and output its index."""
service = _get_entry_service(ctx)
idx = service.add_seed(
label,
index=index,
words=words,
notes=notes,
)
typer.echo(str(idx))
@entry_app.command("add-key-value")
def entry_add_key_value(
ctx: typer.Context,
label: str,
key: str = typer.Option(..., "--key", help="Key name"),
value: str = typer.Option(..., "--value", help="Stored value"),
notes: str = typer.Option("", "--notes", help="Entry notes"),
) -> None:
"""Add a key/value entry and output its index."""
service = _get_entry_service(ctx)
idx = service.add_key_value(label, key, value, notes=notes)
typer.echo(str(idx))
@entry_app.command("add-managed-account")
def entry_add_managed_account(
ctx: typer.Context,
label: str,
index: Optional[int] = typer.Option(None, "--index", help="Derivation index"),
notes: str = typer.Option("", "--notes", help="Entry notes"),
) -> None:
"""Add a managed account seed entry and output its index."""
service = _get_entry_service(ctx)
idx = service.add_managed_account(
label,
index=index,
notes=notes,
)
typer.echo(str(idx))
@entry_app.command("modify")
def entry_modify(
ctx: typer.Context,
entry_id: int,
label: Optional[str] = typer.Option(None, "--label"),
username: Optional[str] = typer.Option(None, "--username"),
url: Optional[str] = typer.Option(None, "--url"),
notes: Optional[str] = typer.Option(None, "--notes"),
period: Optional[int] = typer.Option(
None, "--period", help="TOTP period in seconds"
),
digits: Optional[int] = typer.Option(None, "--digits", help="TOTP digits"),
key: Optional[str] = typer.Option(None, "--key", help="New key"),
value: Optional[str] = typer.Option(None, "--value", help="New value"),
) -> None:
"""Modify an existing entry."""
service = _get_entry_service(ctx)
try:
service.modify_entry(
entry_id,
username=username,
url=url,
notes=notes,
label=label,
period=period,
digits=digits,
key=key,
value=value,
)
except ValueError as e:
typer.echo(str(e))
sys.stdout.flush()
raise typer.Exit(code=1)
@entry_app.command("archive")
def entry_archive(ctx: typer.Context, entry_id: int) -> None:
"""Archive an entry."""
service = _get_entry_service(ctx)
service.archive_entry(entry_id)
typer.echo(str(entry_id))
@entry_app.command("unarchive")
def entry_unarchive(ctx: typer.Context, entry_id: int) -> None:
"""Restore an archived entry."""
service = _get_entry_service(ctx)
service.restore_entry(entry_id)
typer.echo(str(entry_id))
@entry_app.command("totp-codes")
def entry_totp_codes(ctx: typer.Context) -> None:
"""Display all current TOTP codes."""
service = _get_entry_service(ctx)
service.display_totp_codes()
@entry_app.command("export-totp")
def entry_export_totp(
ctx: typer.Context, file: str = typer.Option(..., help="Output file")
) -> None:
"""Export all TOTP secrets to a JSON file."""
service = _get_entry_service(ctx)
data = service.export_totp_entries()
Path(file).write_text(json.dumps(data, indent=2))
typer.echo(str(file))
@vault_app.command("export")
def vault_export(
ctx: typer.Context, file: str = typer.Option(..., help="Output file")
) -> None:
"""Export the vault profile to an encrypted file."""
vault_service, _profile, _sync = _get_services(ctx)
data = vault_service.export_profile()
Path(file).write_bytes(data)
typer.echo(str(file))
@vault_app.command("import")
def vault_import(
ctx: typer.Context, file: str = typer.Option(..., help="Input file")
) -> None:
"""Import a vault profile from an encrypted file."""
vault_service, _profile, _sync = _get_services(ctx)
data = Path(file).read_bytes()
vault_service.import_profile(data)
typer.echo(str(file))
@vault_app.command("change-password")
def vault_change_password(ctx: typer.Context) -> None:
"""Change the master password used for encryption."""
vault_service, _profile, _sync = _get_services(ctx)
old_pw = typer.prompt("Current password", hide_input=True)
new_pw = typer.prompt("New password", hide_input=True, confirmation_prompt=True)
try:
vault_service.change_password(
ChangePasswordRequest(old_password=old_pw, new_password=new_pw)
)
except Exception as exc: # pragma: no cover - pass through errors
typer.echo(f"Error: {exc}")
raise typer.Exit(code=1)
typer.echo("Password updated")
@vault_app.command("unlock")
def vault_unlock(ctx: typer.Context) -> None:
"""Unlock the vault for the active profile."""
vault_service, _profile, _sync = _get_services(ctx)
password = typer.prompt("Master password", hide_input=True)
try:
resp = vault_service.unlock(UnlockRequest(password=password))
except Exception as exc: # pragma: no cover - pass through errors
typer.echo(f"Error: {exc}")
raise typer.Exit(code=1)
typer.echo(f"Unlocked in {resp.duration:.2f}s")
@vault_app.command("lock")
def vault_lock(ctx: typer.Context) -> None:
"""Lock the vault and clear sensitive data from memory."""
vault_service, _profile, _sync = _get_services(ctx)
vault_service.lock()
typer.echo("locked")
@app.command("lock")
def root_lock(ctx: typer.Context) -> None:
"""Lock the vault for the active profile."""
vault_service, _profile, _sync = _get_services(ctx)
vault_service.lock()
typer.echo("locked")
@vault_app.command("stats")
def vault_stats(ctx: typer.Context) -> None:
"""Display statistics about the current seed profile."""
vault_service, _profile, _sync = _get_services(ctx)
stats = vault_service.stats()
typer.echo(json.dumps(stats, indent=2))
@vault_app.command("reveal-parent-seed")
def vault_reveal_parent_seed(
ctx: typer.Context,
file: Optional[str] = typer.Option(
None, "--file", help="Save encrypted seed to this path"
),
) -> None:
"""Display the parent seed and optionally write an encrypted backup file."""
vault_service, _profile, _sync = _get_services(ctx)
password = typer.prompt("Master password", hide_input=True)
vault_service.backup_parent_seed(
BackupParentSeedRequest(path=Path(file) if file else None, password=password)
)
@nostr_app.command("sync")
def nostr_sync(ctx: typer.Context) -> None:
"""Sync with configured Nostr relays."""
_vault, _profile, sync_service = _get_services(ctx)
model = sync_service.sync()
if model:
typer.echo("Event IDs:")
typer.echo(f"- manifest: {model.manifest_id}")
for cid in model.chunk_ids:
typer.echo(f"- chunk: {cid}")
for did in model.delta_ids:
typer.echo(f"- delta: {did}")
else:
typer.echo("Error: Failed to sync vault")
@nostr_app.command("get-pubkey")
def nostr_get_pubkey(ctx: typer.Context) -> None:
"""Display the active profile's npub."""
service = _get_nostr_service(ctx)
npub = service.get_pubkey()
typer.echo(npub)
@nostr_app.command("list-relays")
def nostr_list_relays(ctx: typer.Context) -> None:
"""Display configured Nostr relays."""
service = _get_nostr_service(ctx)
relays = service.list_relays()
for i, r in enumerate(relays, 1):
typer.echo(f"{i}: {r}")
@nostr_app.command("add-relay")
def nostr_add_relay(ctx: typer.Context, url: str) -> None:
"""Add a relay URL."""
service = _get_nostr_service(ctx)
try:
service.add_relay(url)
except Exception as exc: # pragma: no cover - pass through errors
typer.echo(f"Error: {exc}")
raise typer.Exit(code=1)
typer.echo("Added")
@nostr_app.command("remove-relay")
def nostr_remove_relay(ctx: typer.Context, idx: int) -> None:
"""Remove a relay by index (1-based)."""
service = _get_nostr_service(ctx)
try:
service.remove_relay(idx)
except Exception as exc: # pragma: no cover - pass through errors
typer.echo(f"Error: {exc}")
raise typer.Exit(code=1)
typer.echo("Removed")
@config_app.command("get")
def config_get(ctx: typer.Context, key: str) -> None:
"""Get a configuration value."""
service = _get_config_service(ctx)
value = service.get(key)
if value is None:
typer.echo("Key not found")
else:
typer.echo(str(value))
@config_app.command("set")
def config_set(ctx: typer.Context, key: str, value: str) -> None:
"""Set a configuration value."""
service = _get_config_service(ctx)
try:
val = (
[r.strip() for r in value.split(",") if r.strip()]
if key == "relays"
else value
)
service.set(key, val)
except KeyError:
typer.echo("Unknown key")
raise typer.Exit(code=1)
except Exception as exc: # pragma: no cover - pass through errors
typer.echo(f"Error: {exc}")
raise typer.Exit(code=1)
typer.echo("Updated")
@config_app.command("toggle-secret-mode")
def config_toggle_secret_mode(ctx: typer.Context) -> None:
"""Interactively enable or disable secret mode.
When enabled, newly generated and retrieved passwords are copied to the
clipboard instead of printed to the screen.
"""
service = _get_config_service(ctx)
try:
enabled = service.get_secret_mode_enabled()
delay = service.get_clipboard_clear_delay()
except Exception as exc: # pragma: no cover - pass through errors
typer.echo(f"Error loading settings: {exc}")
raise typer.Exit(code=1)
typer.echo(f"Secret mode is currently {'ON' if enabled else 'OFF'}")
choice = (
typer.prompt(
"Enable secret mode? (y/n, blank to keep)", default="", show_default=False
)
.strip()
.lower()
)
if choice in ("y", "yes"):
enabled = True
elif choice in ("n", "no"):
enabled = False
inp = typer.prompt(
f"Clipboard clear delay in seconds [{delay}]", default="", show_default=False
).strip()
if inp:
try:
delay = int(inp)
if delay <= 0:
typer.echo("Delay must be positive")
raise typer.Exit(code=1)
except ValueError:
typer.echo("Invalid number")
raise typer.Exit(code=1)
try:
service.set_secret_mode(enabled, delay)
except Exception as exc: # pragma: no cover - pass through errors
typer.echo(f"Error: {exc}")
raise typer.Exit(code=1)
status = "enabled" if enabled else "disabled"
typer.echo(f"Secret mode {status}.")
@config_app.command("toggle-offline")
def config_toggle_offline(ctx: typer.Context) -> None:
"""Enable or disable offline mode."""
service = _get_config_service(ctx)
try:
enabled = service.get_offline_mode()
except Exception as exc: # pragma: no cover - pass through errors
typer.echo(f"Error loading settings: {exc}")
raise typer.Exit(code=1)
typer.echo(f"Offline mode is currently {'ON' if enabled else 'OFF'}")
choice = (
typer.prompt(
"Enable offline mode? (y/n, blank to keep)", default="", show_default=False
)
.strip()
.lower()
)
if choice in ("y", "yes"):
enabled = True
elif choice in ("n", "no"):
enabled = False
try:
service.set_offline_mode(enabled)
except Exception as exc: # pragma: no cover - pass through errors
typer.echo(f"Error: {exc}")
raise typer.Exit(code=1)
status = "enabled" if enabled else "disabled"
typer.echo(f"Offline mode {status}.")
@fingerprint_app.command("list")
def fingerprint_list(ctx: typer.Context) -> None:
"""List available seed profiles."""
_vault, profile_service, _sync = _get_services(ctx)
for fp in profile_service.list_profiles():
typer.echo(fp)
@fingerprint_app.command("add")
def fingerprint_add(ctx: typer.Context) -> None:
"""Create a new seed profile."""
_vault, profile_service, _sync = _get_services(ctx)
profile_service.add_profile()
@fingerprint_app.command("remove")
def fingerprint_remove(ctx: typer.Context, fingerprint: str) -> None:
"""Remove a seed profile."""
_vault, profile_service, _sync = _get_services(ctx)
profile_service.remove_profile(ProfileRemoveRequest(fingerprint=fingerprint))
@fingerprint_app.command("switch")
def fingerprint_switch(ctx: typer.Context, fingerprint: str) -> None:
"""Switch to another seed profile."""
_vault, profile_service, _sync = _get_services(ctx)
password = typer.prompt("Master password", hide_input=True)
profile_service.switch_profile(
ProfileSwitchRequest(fingerprint=fingerprint, password=password)
)
@util_app.command("generate-password")
def generate_password(
ctx: typer.Context,
length: int = 24,
no_special: bool = typer.Option(
False, "--no-special", help="Exclude special characters", is_flag=True
),
allowed_special_chars: Optional[str] = typer.Option(
None, "--allowed-special-chars", help="Explicit set of special characters"
),
special_mode: Optional[str] = typer.Option(
None,
"--special-mode",
help="Special character mode",
),
exclude_ambiguous: bool = typer.Option(
False,
"--exclude-ambiguous",
help="Exclude ambiguous characters",
is_flag=True,
),
min_uppercase: Optional[int] = typer.Option(None, "--min-uppercase"),
min_lowercase: Optional[int] = typer.Option(None, "--min-lowercase"),
min_digits: Optional[int] = typer.Option(None, "--min-digits"),
min_special: Optional[int] = typer.Option(None, "--min-special"),
) -> None:
"""Generate a strong password."""
service = _get_util_service(ctx)
kwargs = {}
if no_special:
kwargs["include_special_chars"] = False
if allowed_special_chars is not None:
kwargs["allowed_special_chars"] = allowed_special_chars
if special_mode is not None:
kwargs["special_mode"] = special_mode
if exclude_ambiguous:
kwargs["exclude_ambiguous"] = True
if min_uppercase is not None:
kwargs["min_uppercase"] = min_uppercase
if min_lowercase is not None:
kwargs["min_lowercase"] = min_lowercase
if min_digits is not None:
kwargs["min_digits"] = min_digits
if min_special is not None:
kwargs["min_special"] = min_special
password = service.generate_password(length, **kwargs)
typer.echo(password)
@util_app.command("verify-checksum")
def verify_checksum(ctx: typer.Context) -> None:
"""Verify the SeedPass script checksum."""
service = _get_util_service(ctx)
service.verify_checksum()
@util_app.command("update-checksum")
def update_checksum(ctx: typer.Context) -> None:
"""Regenerate the script checksum file."""
service = _get_util_service(ctx)
service.update_checksum()
@api_app.command("start")
def api_start(
ctx: typer.Context,
host: str = "127.0.0.1",
port: int = 8000,
token: Optional[str] = typer.Option(None, "--token", help="API token"),
) -> None:
"""Start the SeedPass API server."""
actual_token = api_module.start_server(ctx.obj.get("fingerprint"), token=token)
if not os.getenv("SEEDPASS_API_TOKEN") and token is None:
typer.echo(f"API token: {actual_token}", err=True)
uvicorn.run(api_module.app, host=host, port=port)
@api_app.command("stop")
def api_stop(ctx: typer.Context, host: str = "127.0.0.1", port: int = 8000) -> None:
"""Stop the SeedPass API server."""
import requests
token = os.getenv("SEEDPASS_API_TOKEN", api_module._token)
try:
requests.post(
f"http://{host}:{port}/api/v1/shutdown",
headers={"Authorization": f"Bearer {token}"},
timeout=2,
)
except Exception as exc: # pragma: no cover - best effort
typer.echo(f"Failed to stop server: {exc}")
@app.command()
def gui() -> None:
"""Launch the BeeWare GUI.
If the platform specific backend is missing, attempt to install it and
retry launching the GUI.
"""
if not _gui_backend_available():
if sys.platform.startswith("linux"):
pkg = "toga-gtk"
elif sys.platform == "win32":
pkg = "toga-winforms"
elif sys.platform == "darwin":
pkg = "toga-cocoa"
else:
typer.echo(
f"Unsupported platform '{sys.platform}' for BeeWare GUI.",
err=True,
)
raise typer.Exit(1)
typer.echo(f"Attempting to install {pkg} for GUI support...")
try:
subprocess.check_call([sys.executable, "-m", "pip", "install", pkg])
typer.echo(f"Successfully installed {pkg}.")
except subprocess.CalledProcessError as exc:
typer.echo(f"Failed to install {pkg}: {exc}", err=True)
raise typer.Exit(1)
if not _gui_backend_available():
typer.echo(
"BeeWare GUI backend still unavailable after installation attempt.",
err=True,
)
raise typer.Exit(1)
from seedpass_gui.app import main
main()
if __name__ == "__main__":
app()
+185
View File
@@ -0,0 +1,185 @@
from __future__ import annotations
import importlib
import importlib.util
import subprocess
import sys
from typing import Optional
import typer
from .common import _get_services
from seedpass.core.errors import SeedPassError
app = typer.Typer(
help="SeedPass command line interface",
invoke_without_command=True,
)
# Global option shared across all commands
fingerprint_option = typer.Option(
None,
"--fingerprint",
"-f",
help="Specify which seed profile to use",
)
no_clipboard_option = typer.Option(
False,
"--no-clipboard",
help="Disable clipboard support and print secrets instead",
)
deterministic_totp_option = typer.Option(
False,
"--deterministic-totp",
help="Derive TOTP secrets deterministically",
)
# Sub command groups
from . import entry, vault, nostr, config, fingerprint, util, api, agent
app.add_typer(entry.app, name="entry")
app.add_typer(vault.app, name="vault")
app.add_typer(nostr.app, name="nostr")
app.add_typer(config.app, name="config")
app.add_typer(fingerprint.app, name="fingerprint")
app.add_typer(util.app, name="util")
app.add_typer(api.app, name="api")
app.add_typer(agent.app, name="agent")
def run() -> None:
"""Invoke the CLI, handling SeedPass errors gracefully."""
try:
app()
except SeedPassError as exc:
typer.echo(str(exc), err=True)
raise typer.Exit(1) from exc
def _gui_backend_available() -> bool:
"""Return True if a platform-specific BeeWare backend is installed."""
for pkg in ("toga_gtk", "toga_winforms", "toga_cocoa"):
if importlib.util.find_spec(pkg) is not None:
return True
return False
@app.callback(invoke_without_command=True)
def main(
ctx: typer.Context,
fingerprint: Optional[str] = fingerprint_option,
no_clipboard: bool = no_clipboard_option,
deterministic_totp: bool = deterministic_totp_option,
) -> None:
"""SeedPass CLI entry point.
When called without a subcommand this launches the interactive TUI.
"""
ctx.obj = {
"fingerprint": fingerprint,
"no_clipboard": no_clipboard,
"deterministic_totp": deterministic_totp,
}
if ctx.invoked_subcommand is None:
tui = importlib.import_module("main")
raise typer.Exit(tui.main(fingerprint=fingerprint))
@app.command("lock")
def root_lock(ctx: typer.Context) -> None:
"""Lock the vault for the active profile."""
vault_service, _profile, _sync = _get_services(ctx)
vault_service.lock()
typer.echo("locked")
@app.command()
def gui(
install: bool = typer.Option(
False,
"--install",
help="Attempt to install the BeeWare GUI backend if missing",
)
) -> None:
"""Launch the BeeWare GUI.
If a platform specific backend is missing, inform the user how to
install it. Using ``--install`` will attempt installation after
confirmation.
"""
if not _gui_backend_available():
if sys.platform.startswith("linux"):
pkg = "toga-gtk"
version = "0.5.2"
sha256 = "15b346ac1a2584de5effe5e73a3888f055c68c93300aeb111db9d64186b31646"
elif sys.platform == "win32":
pkg = "toga-winforms"
version = "0.5.2"
sha256 = "83181309f204bcc4a34709d23fdfd68467ae8ecc39c906d13c661cb9a0ef581b"
elif sys.platform == "darwin":
pkg = "toga-cocoa"
version = "0.5.2"
sha256 = "a4d5d1546bf92372a6fb1b450164735fb107b2ee69d15bf87421fec3c78465f9"
else:
typer.echo(
f"Unsupported platform '{sys.platform}' for BeeWare GUI.",
err=True,
)
raise typer.Exit(1)
if not install:
typer.echo(
f"BeeWare GUI backend not found. Please install {pkg} manually or rerun "
"with '--install'.",
err=True,
)
raise typer.Exit(1)
if not typer.confirm(
f"Install {pkg}=={version} with hash verification?", default=False
):
typer.echo("Installation cancelled.", err=True)
raise typer.Exit(1)
typer.echo(
"SeedPass uses pinned versions and SHA256 hashes to verify the GUI backend "
"and protect against tampered packages."
)
try:
subprocess.check_call(
[
sys.executable,
"-m",
"pip",
"install",
"--require-hashes",
f"{pkg}=={version}",
f"--hash=sha256:{sha256}",
]
)
typer.echo(f"Successfully installed {pkg}=={version}.")
except subprocess.CalledProcessError as exc:
typer.echo(
"Secure installation failed. Please install the package manually "
f"from a trusted source. Details: {exc}",
err=True,
)
raise typer.Exit(1)
if not _gui_backend_available():
typer.echo(
"BeeWare GUI backend still unavailable after installation attempt.",
err=True,
)
raise typer.Exit(1)
from seedpass_gui.app import main
main()
if __name__ == "__main__": # pragma: no cover
run()
+378
View File
@@ -0,0 +1,378 @@
from __future__ import annotations
import json
import os
from datetime import UTC, datetime, timedelta
from pathlib import Path
from typing import Optional
import bcrypt
import click
import typer
from bip_utils import Bip39Languages, Bip39MnemonicGenerator, Bip39WordsNum
from mnemonic import Mnemonic
from constants import APP_DIR, initialize_app
from seedpass.core.api import EntryService
from seedpass.core.entry_types import ALL_ENTRY_TYPES, EntryType
from seedpass.core.encryption import EncryptionManager
from seedpass.core.manager import PasswordManager
from seedpass.core.vault import Vault
from seedpass.core.config_manager import ConfigManager
from utils.fingerprint import generate_fingerprint
from utils.fingerprint_manager import FingerprintManager
from utils.key_derivation import derive_index_key, derive_key_from_password
app = typer.Typer(help="Agent-first non-interactive workflows")
PRIVATE_KINDS = {
EntryType.SEED.value,
EntryType.SSH.value,
EntryType.PGP.value,
EntryType.NOSTR.value,
EntryType.MANAGED_ACCOUNT.value,
}
DEFAULT_POLICY = {
"allow_kinds": [
EntryType.PASSWORD.value,
EntryType.TOTP.value,
EntryType.KEY_VALUE.value,
],
"deny_private_reveal": sorted(PRIVATE_KINDS),
"allow_export_import": False,
}
def _policy_path() -> Path:
return APP_DIR / "agent_policy.json"
def _deny_all_policy() -> dict:
return {
"allow_kinds": [],
"deny_private_reveal": list(ALL_ENTRY_TYPES),
"allow_export_import": False,
}
def _load_policy(*, strict: bool = False) -> dict:
path = _policy_path()
if not path.exists():
return dict(DEFAULT_POLICY)
try:
data = json.loads(path.read_text(encoding="utf-8"))
except Exception as exc:
if strict:
raise ValueError("agent policy file is not valid JSON") from exc
return _deny_all_policy()
if not isinstance(data, dict):
if strict:
raise ValueError("agent policy file must be a JSON object")
return _deny_all_policy()
policy = dict(DEFAULT_POLICY)
if isinstance(data.get("allow_kinds"), list):
allow_kinds = [str(v) for v in data["allow_kinds"]]
invalid = [v for v in allow_kinds if v not in ALL_ENTRY_TYPES]
if invalid and strict:
raise ValueError(f"agent policy has invalid allow_kinds entries: {invalid}")
policy["allow_kinds"] = [v for v in allow_kinds if v in ALL_ENTRY_TYPES]
if isinstance(data.get("deny_private_reveal"), list):
deny_private = [str(v) for v in data["deny_private_reveal"]]
invalid = [v for v in deny_private if v not in ALL_ENTRY_TYPES]
if invalid and strict:
raise ValueError(
f"agent policy has invalid deny_private_reveal entries: {invalid}"
)
policy["deny_private_reveal"] = [v for v in deny_private if v in ALL_ENTRY_TYPES]
if isinstance(data.get("allow_export_import"), bool):
policy["allow_export_import"] = data["allow_export_import"]
return policy
def _save_policy(policy: dict) -> None:
path = _policy_path()
path.parent.mkdir(parents=True, exist_ok=True)
path.write_text(json.dumps(policy, indent=2), encoding="utf-8")
os.chmod(path, 0o600)
def _password_from_env(var_name: str) -> str:
value = os.getenv(var_name)
if not value:
raise typer.BadParameter(
f"Missing password env var '{var_name}'. Export it before running this command."
)
return value
def _seed_from_inputs(
seed: Optional[str],
seed_file: Optional[Path],
generate_seed: bool,
) -> tuple[str, bool]:
choices = int(bool(seed)) + int(bool(seed_file)) + int(bool(generate_seed))
if choices != 1:
raise typer.BadParameter(
"Provide exactly one of --seed, --seed-file, or --generate-seed."
)
if seed_file:
seed = seed_file.read_text(encoding="utf-8").strip()
elif generate_seed:
seed = (
Bip39MnemonicGenerator(Bip39Languages.ENGLISH)
.FromWordsNumber(Bip39WordsNum.WORDS_NUM_12)
.ToStr()
)
assert seed is not None
if not Mnemonic("english").check(seed):
raise typer.BadParameter("Invalid BIP-39 seed phrase.")
return seed, bool(generate_seed)
@app.command("init")
def agent_init(
seed: Optional[str] = typer.Option(
None, "--seed", help="BIP-39 seed phrase (avoid shell history for secrets)"
),
seed_file: Optional[Path] = typer.Option(
None, "--seed-file", help="Path to a file containing the seed phrase"
),
generate_seed: bool = typer.Option(
False, "--generate-seed", help="Generate a fresh 12-word seed"
),
password_env: str = typer.Option(
"SEEDPASS_PASSWORD", "--password-env", help="Env var containing master password"
),
switch_existing: bool = typer.Option(
False,
"--switch-existing",
help="Switch to existing profile if the seed fingerprint already exists",
),
kdf_iterations: int = typer.Option(
100_000, "--kdf-iterations", help="PBKDF2 iterations for profile setup"
),
print_seed: bool = typer.Option(
False, "--print-seed", help="Include generated seed in JSON output"
),
) -> None:
"""Initialize a profile non-interactively for agent workflows."""
initialize_app()
password = _password_from_env(password_env)
seed_phrase, generated = _seed_from_inputs(seed, seed_file, generate_seed)
fp_mgr = FingerprintManager(APP_DIR)
fingerprint = None
created = False
try:
fingerprint = fp_mgr.add_fingerprint(seed_phrase)
created = True
except ValueError:
fingerprint = generate_fingerprint(seed_phrase)
if not switch_existing:
raise typer.BadParameter(
"Seed profile already exists. Use --switch-existing to reuse it."
)
assert fingerprint is not None
profile_dir = fp_mgr.get_fingerprint_directory(fingerprint)
if profile_dir is None:
raise typer.BadParameter("Could not resolve profile directory for fingerprint.")
profile_dir.mkdir(parents=True, exist_ok=True)
seed_key = derive_key_from_password(
password, fingerprint, iterations=kdf_iterations
)
seed_mgr = EncryptionManager(seed_key, profile_dir)
seed_mgr.encrypt_parent_seed(seed_phrase)
index_key = derive_index_key(seed_phrase)
enc_mgr = EncryptionManager(index_key, profile_dir)
vault = Vault(enc_mgr, profile_dir)
cfg = ConfigManager(vault=vault, fingerprint_dir=profile_dir)
cfg.set_password_hash(bcrypt.hashpw(password.encode(), bcrypt.gensalt()).decode())
cfg.set_kdf_iterations(kdf_iterations)
cfg.set_offline_mode(True)
cfg.set_secret_mode_enabled(False)
cfg.set_quick_unlock(False)
if not _policy_path().exists():
_save_policy(dict(DEFAULT_POLICY))
payload = {
"status": "ok",
"created": created,
"fingerprint": fingerprint,
"profile_dir": str(profile_dir),
"generated_seed": seed_phrase if generated and print_seed else None,
}
typer.echo(json.dumps(payload, indent=2))
@app.command("policy-show")
def agent_policy_show() -> None:
"""Show the active agent policy."""
try:
policy = _load_policy(strict=True)
except ValueError as exc:
raise typer.BadParameter(str(exc))
typer.echo(json.dumps(policy, indent=2))
@app.command("policy-set")
def agent_policy_set(
allow_kind: list[str] = typer.Option(
None,
"--allow-kind",
help="Allowed entry kind for agent retrieval (repeatable)",
click_type=click.Choice(ALL_ENTRY_TYPES),
),
deny_private_kind: list[str] = typer.Option(
None,
"--deny-private-kind",
help="Kind denied from agent reveal (repeatable)",
click_type=click.Choice(ALL_ENTRY_TYPES),
),
allow_export_import: bool = typer.Option(
False,
"--allow-export-import",
help="Allow export/import operations in agent mode",
),
) -> None:
"""Set policy flags used by agent commands."""
policy = _load_policy(strict=False)
if allow_kind:
policy["allow_kinds"] = list(allow_kind)
if deny_private_kind:
policy["deny_private_reveal"] = list(deny_private_kind)
policy["allow_export_import"] = bool(allow_export_import)
_save_policy(policy)
typer.echo(json.dumps(policy, indent=2))
@app.command("get")
def agent_get(
ctx: typer.Context,
query: str = typer.Argument(..., help="Entry label or index query"),
password_env: str = typer.Option(
"SEEDPASS_PASSWORD", "--password-env", help="Env var containing master password"
),
ttl: int = typer.Option(
30,
"--ttl",
min=1,
help="Lease time in seconds for the returned secret metadata",
),
) -> None:
"""Retrieve one secret as JSON with policy enforcement."""
fingerprint = (ctx.obj or {}).get("fingerprint")
if not fingerprint:
raise typer.BadParameter("Specify target profile with --fingerprint.")
password = _password_from_env(password_env)
try:
policy = _load_policy(strict=True)
except ValueError as exc:
typer.echo(
json.dumps(
{
"status": "denied",
"reason": "invalid_policy",
"detail": str(exc),
},
indent=2,
)
)
raise typer.Exit(1)
pm = PasswordManager(fingerprint=fingerprint, password=password)
service = EntryService(pm)
matches = service.search_entries(query)
if len(matches) != 1:
payload = {
"status": "error",
"reason": "ambiguous_or_missing",
"match_count": len(matches),
"matches": [
{
"index": idx,
"label": label,
"kind": etype.value,
"username": username,
"url": url,
"archived": archived,
}
for idx, label, username, url, archived, etype in matches
],
}
typer.echo(json.dumps(payload, indent=2))
raise typer.Exit(1)
index = matches[0][0]
entry = service.retrieve_entry(index)
kind = entry.get("type", entry.get("kind"))
if kind is None:
raise typer.BadParameter("Entry kind missing.")
allow_kinds = set(policy.get("allow_kinds", []))
deny_private = set(policy.get("deny_private_reveal", []))
if allow_kinds and kind not in allow_kinds:
typer.echo(
json.dumps(
{
"status": "denied",
"reason": "kind_not_allowed",
"kind": kind,
"policy": policy,
},
indent=2,
)
)
raise typer.Exit(1)
if kind in deny_private:
typer.echo(
json.dumps(
{
"status": "denied",
"reason": "private_kind_blocked",
"kind": kind,
"policy": policy,
},
indent=2,
)
)
raise typer.Exit(1)
secret: Optional[str] = None
if kind == EntryType.PASSWORD.value:
secret = service.generate_password(int(entry.get("length", 12)), index)
elif kind == EntryType.TOTP.value:
secret = service.get_totp_code(index)
elif kind == EntryType.KEY_VALUE.value:
secret = entry.get("value")
else:
typer.echo(
json.dumps(
{
"status": "error",
"reason": "unsupported_kind_for_agent_get",
"kind": kind,
},
indent=2,
)
)
raise typer.Exit(1)
now = datetime.now(UTC)
payload = {
"status": "ok",
"fingerprint": fingerprint,
"index": index,
"kind": kind,
"label": entry.get("label"),
"lease_ttl_sec": ttl,
"issued_at_utc": now.isoformat(),
"expires_at_utc": (now + timedelta(seconds=ttl)).isoformat(),
"secret": secret,
}
typer.echo(json.dumps(payload, indent=2))
+37
View File
@@ -0,0 +1,37 @@
from __future__ import annotations
import typer
import uvicorn
from .. import api as api_module
app = typer.Typer(help="Run the API server")
@app.command("start")
def api_start(ctx: typer.Context, host: str = "127.0.0.1", port: int = 8000) -> None:
"""Start the SeedPass API server."""
token = api_module.start_server(ctx.obj.get("fingerprint"))
typer.echo(
f"API token: {token}\nWARNING: Store this token securely; it cannot be recovered."
)
uvicorn.run(api_module.app, host=host, port=port)
@app.command("stop")
def api_stop(
token: str = typer.Option(..., help="API token"),
host: str = "127.0.0.1",
port: int = 8000,
) -> None:
"""Stop the SeedPass API server."""
import requests
try:
requests.post(
f"http://{host}:{port}/api/v1/shutdown",
headers={"Authorization": f"Bearer {token}"},
timeout=2,
)
except Exception as exc: # pragma: no cover - best effort
typer.echo(f"Failed to stop server: {exc}")
+63
View File
@@ -0,0 +1,63 @@
from __future__ import annotations
import typer
from seedpass.core.manager import PasswordManager
from seedpass.core.entry_types import EntryType
from seedpass.core.api import (
VaultService,
ProfileService,
SyncService,
EntryService,
ConfigService,
UtilityService,
NostrService,
ChangePasswordRequest,
UnlockRequest,
BackupParentSeedRequest,
ProfileSwitchRequest,
ProfileRemoveRequest,
)
def _get_pm(ctx: typer.Context) -> PasswordManager:
"""Return a PasswordManager optionally selecting a fingerprint."""
fp = ctx.obj.get("fingerprint")
if fp is None:
pm = PasswordManager()
else:
pm = PasswordManager(fingerprint=fp)
if ctx.obj.get("no_clipboard"):
pm.secret_mode_enabled = False
if ctx.obj.get("deterministic_totp"):
pm.deterministic_totp = True
return pm
def _get_services(
ctx: typer.Context,
) -> tuple[VaultService, ProfileService, SyncService]:
"""Return service layer instances for the current context."""
pm = _get_pm(ctx)
return VaultService(pm), ProfileService(pm), SyncService(pm)
def _get_entry_service(ctx: typer.Context) -> EntryService:
pm = _get_pm(ctx)
return EntryService(pm)
def _get_config_service(ctx: typer.Context) -> ConfigService:
pm = _get_pm(ctx)
return ConfigService(pm)
def _get_util_service(ctx: typer.Context) -> UtilityService:
pm = _get_pm(ctx)
return UtilityService(pm)
def _get_nostr_service(ctx: typer.Context) -> NostrService:
pm = _get_pm(ctx)
return NostrService(pm)
+124
View File
@@ -0,0 +1,124 @@
from __future__ import annotations
import typer
from .common import _get_config_service
app = typer.Typer(help="Get or set configuration values")
@app.command("get")
def config_get(ctx: typer.Context, key: str) -> None:
"""Get a configuration value."""
service = _get_config_service(ctx)
value = service.get(key)
if value is None:
typer.echo("Key not found")
else:
typer.echo(str(value))
@app.command("set")
def config_set(ctx: typer.Context, key: str, value: str) -> None:
"""Set a configuration value."""
service = _get_config_service(ctx)
try:
val = (
[r.strip() for r in value.split(",") if r.strip()]
if key == "relays"
else value
)
service.set(key, val)
except KeyError:
typer.echo("Unknown key")
raise typer.Exit(code=1)
except Exception as exc: # pragma: no cover - pass through errors
typer.echo(f"Error: {exc}")
raise typer.Exit(code=1)
typer.echo("Updated")
@app.command("toggle-secret-mode")
def config_toggle_secret_mode(ctx: typer.Context) -> None:
"""Interactively enable or disable secret mode.
When enabled, newly generated and retrieved passwords are copied to the
clipboard instead of printed to the screen.
"""
service = _get_config_service(ctx)
try:
enabled = service.get_secret_mode_enabled()
delay = service.get_clipboard_clear_delay()
except Exception as exc: # pragma: no cover - pass through errors
typer.echo(f"Error loading settings: {exc}")
raise typer.Exit(code=1)
typer.echo(f"Secret mode is currently {'ON' if enabled else 'OFF'}")
choice = (
typer.prompt(
"Enable secret mode? (y/n, blank to keep)", default="", show_default=False
)
.strip()
.lower()
)
if choice in ("y", "yes"):
enabled = True
elif choice in ("n", "no"):
enabled = False
inp = typer.prompt(
f"Clipboard clear delay in seconds [{delay}]", default="", show_default=False
).strip()
if inp:
try:
delay = int(inp)
if delay <= 0:
typer.echo("Delay must be positive")
raise typer.Exit(code=1)
except ValueError:
typer.echo("Invalid number")
raise typer.Exit(code=1)
try:
service.set_secret_mode(enabled, delay)
except Exception as exc: # pragma: no cover - pass through errors
typer.echo(f"Error: {exc}")
raise typer.Exit(code=1)
status = "enabled" if enabled else "disabled"
typer.echo(f"Secret mode {status}.")
@app.command("toggle-offline")
def config_toggle_offline(ctx: typer.Context) -> None:
"""Enable or disable offline mode."""
service = _get_config_service(ctx)
try:
enabled = service.get_offline_mode()
except Exception as exc: # pragma: no cover - pass through errors
typer.echo(f"Error loading settings: {exc}")
raise typer.Exit(code=1)
typer.echo(f"Offline mode is currently {'ON' if enabled else 'OFF'}")
choice = (
typer.prompt(
"Enable offline mode? (y/n, blank to keep)", default="", show_default=False
)
.strip()
.lower()
)
if choice in ("y", "yes"):
enabled = True
elif choice in ("n", "no"):
enabled = False
try:
service.set_offline_mode(enabled)
except Exception as exc: # pragma: no cover - pass through errors
typer.echo(f"Error: {exc}")
raise typer.Exit(code=1)
status = "enabled" if enabled else "disabled"
typer.echo(f"Offline mode {status}.")
+366
View File
@@ -0,0 +1,366 @@
from __future__ import annotations
import json
import sys
from pathlib import Path
from typing import List, Optional
import typer
import click
from .common import _get_entry_service, EntryType
from seedpass.core.entry_types import ALL_ENTRY_TYPES
from utils.clipboard import ClipboardUnavailableError
app = typer.Typer(help="Manage individual entries")
@app.command("list")
def entry_list(
ctx: typer.Context,
sort: str = typer.Option(
"index", "--sort", help="Sort by 'index', 'label', or 'updated'"
),
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"),
) -> None:
"""List entries in the vault."""
service = _get_entry_service(ctx)
entries = service.list_entries(
sort_by=sort,
filter_kinds=[kind] if kind else None,
include_archived=archived,
)
for idx, label, username, url, is_archived in entries:
line = f"{idx}: {label}"
if username:
line += f" ({username})"
if url:
line += f" {url}"
if is_archived:
line += " [archived]"
typer.echo(line)
@app.command("search")
def entry_search(
ctx: typer.Context,
query: str,
kinds: List[str] = typer.Option(
None,
"--kind",
"-k",
help="Filter by entry kinds (can be repeated)",
click_type=click.Choice(ALL_ENTRY_TYPES),
),
) -> None:
"""Search entries."""
service = _get_entry_service(ctx)
kinds = list(kinds) if kinds else None
results = service.search_entries(query, kinds=kinds)
if not results:
typer.echo("No matching entries found")
return
for idx, label, username, url, _arch, etype in results:
line = f"{idx}: {etype.value.replace('_', ' ').title()} - {label}"
if username:
line += f" ({username})"
if url:
line += f" {url}"
typer.echo(line)
@app.command("get")
def entry_get(ctx: typer.Context, query: str) -> None:
"""Retrieve a single entry's secret."""
service = _get_entry_service(ctx)
try:
matches = service.search_entries(query)
if len(matches) == 0:
typer.echo("No matching entries found")
raise typer.Exit(code=1)
if len(matches) > 1:
typer.echo("Matches:")
for idx, label, username, _url, _arch, etype in matches:
name = f"{idx}: {etype.value.replace('_', ' ').title()} - {label}"
if username:
name += f" ({username})"
typer.echo(name)
raise typer.Exit(code=1)
index = matches[0][0]
entry = service.retrieve_entry(index)
etype = entry.get("type", entry.get("kind"))
if etype == EntryType.PASSWORD.value:
length = int(entry.get("length", 12))
password = service.generate_password(length, index)
typer.echo(password)
elif etype == EntryType.TOTP.value:
code = service.get_totp_code(index)
typer.echo(code)
else:
typer.echo("Unsupported entry type")
raise typer.Exit(code=1)
except ClipboardUnavailableError as exc:
typer.echo(
f"Clipboard unavailable: {exc}\n"
"Re-run with '--no-clipboard' to print secrets instead.",
err=True,
)
raise typer.Exit(code=1)
@app.command("add")
def entry_add(
ctx: typer.Context,
label: str,
length: int = typer.Option(12, "--length"),
username: Optional[str] = typer.Option(None, "--username"),
url: Optional[str] = typer.Option(None, "--url"),
no_special: bool = typer.Option(
False, "--no-special", help="Exclude special characters"
),
allowed_special_chars: Optional[str] = typer.Option(
None, "--allowed-special-chars", help="Explicit set of special characters"
),
special_mode: Optional[str] = typer.Option(
None,
"--special-mode",
help="Special character mode",
),
exclude_ambiguous: bool = typer.Option(
False,
"--exclude-ambiguous",
help="Exclude ambiguous characters",
),
min_uppercase: Optional[int] = typer.Option(None, "--min-uppercase"),
min_lowercase: Optional[int] = typer.Option(None, "--min-lowercase"),
min_digits: Optional[int] = typer.Option(None, "--min-digits"),
min_special: Optional[int] = typer.Option(None, "--min-special"),
) -> None:
"""Add a new password entry and output its index."""
service = _get_entry_service(ctx)
kwargs = {}
if no_special:
kwargs["include_special_chars"] = False
if allowed_special_chars is not None:
kwargs["allowed_special_chars"] = allowed_special_chars
if special_mode is not None:
kwargs["special_mode"] = special_mode
if exclude_ambiguous:
kwargs["exclude_ambiguous"] = True
if min_uppercase is not None:
kwargs["min_uppercase"] = min_uppercase
if min_lowercase is not None:
kwargs["min_lowercase"] = min_lowercase
if min_digits is not None:
kwargs["min_digits"] = min_digits
if min_special is not None:
kwargs["min_special"] = min_special
index = service.add_entry(label, length, username, url, **kwargs)
typer.echo(str(index))
@app.command("add-totp")
def entry_add_totp(
ctx: typer.Context,
label: str,
index: Optional[int] = typer.Option(None, "--index", help="Derivation index"),
secret: Optional[str] = typer.Option(None, "--secret", help="Import secret"),
period: int = typer.Option(30, "--period", help="TOTP period in seconds"),
digits: int = typer.Option(6, "--digits", help="Number of TOTP digits"),
deterministic_totp: bool = typer.Option(
False, "--deterministic-totp", help="Derive secret deterministically"
),
) -> None:
"""Add a TOTP entry and output the otpauth URI."""
service = _get_entry_service(ctx)
uri = service.add_totp(
label,
index=index,
secret=secret,
period=period,
digits=digits,
deterministic=deterministic_totp,
)
typer.echo(uri)
@app.command("add-ssh")
def entry_add_ssh(
ctx: typer.Context,
label: str,
index: Optional[int] = typer.Option(None, "--index", help="Derivation index"),
notes: str = typer.Option("", "--notes", help="Entry notes"),
) -> None:
"""Add an SSH key entry and output its index."""
service = _get_entry_service(ctx)
idx = service.add_ssh_key(
label,
index=index,
notes=notes,
)
typer.echo(str(idx))
@app.command("add-pgp")
def entry_add_pgp(
ctx: typer.Context,
label: str,
index: Optional[int] = typer.Option(None, "--index", help="Derivation index"),
key_type: str = typer.Option("ed25519", "--key-type", help="Key type"),
user_id: str = typer.Option("", "--user-id", help="User ID"),
notes: str = typer.Option("", "--notes", help="Entry notes"),
) -> None:
"""Add a PGP key entry and output its index."""
service = _get_entry_service(ctx)
idx = service.add_pgp_key(
label,
index=index,
key_type=key_type,
user_id=user_id,
notes=notes,
)
typer.echo(str(idx))
@app.command("add-nostr")
def entry_add_nostr(
ctx: typer.Context,
label: str,
index: Optional[int] = typer.Option(None, "--index", help="Derivation index"),
notes: str = typer.Option("", "--notes", help="Entry notes"),
) -> None:
"""Add a Nostr key entry and output its index."""
service = _get_entry_service(ctx)
idx = service.add_nostr_key(
label,
index=index,
notes=notes,
)
typer.echo(str(idx))
@app.command("add-seed")
def entry_add_seed(
ctx: typer.Context,
label: str,
index: Optional[int] = typer.Option(None, "--index", help="Derivation index"),
words: int = typer.Option(24, "--words", help="Word count"),
notes: str = typer.Option("", "--notes", help="Entry notes"),
) -> None:
"""Add a derived seed phrase entry and output its index."""
service = _get_entry_service(ctx)
idx = service.add_seed(
label,
index=index,
words=words,
notes=notes,
)
typer.echo(str(idx))
@app.command("add-key-value")
def entry_add_key_value(
ctx: typer.Context,
label: str,
key: str = typer.Option(..., "--key", help="Key name"),
value: str = typer.Option(..., "--value", help="Stored value"),
notes: str = typer.Option("", "--notes", help="Entry notes"),
) -> None:
"""Add a key/value entry and output its index."""
service = _get_entry_service(ctx)
idx = service.add_key_value(label, key, value, notes=notes)
typer.echo(str(idx))
@app.command("add-managed-account")
def entry_add_managed_account(
ctx: typer.Context,
label: str,
index: Optional[int] = typer.Option(None, "--index", help="Derivation index"),
notes: str = typer.Option("", "--notes", help="Entry notes"),
) -> None:
"""Add a managed account seed entry and output its index."""
service = _get_entry_service(ctx)
idx = service.add_managed_account(
label,
index=index,
notes=notes,
)
typer.echo(str(idx))
@app.command("modify")
def entry_modify(
ctx: typer.Context,
entry_id: int,
label: Optional[str] = typer.Option(None, "--label"),
username: Optional[str] = typer.Option(None, "--username"),
url: Optional[str] = typer.Option(None, "--url"),
notes: Optional[str] = typer.Option(None, "--notes"),
period: Optional[int] = typer.Option(
None, "--period", help="TOTP period in seconds"
),
digits: Optional[int] = typer.Option(None, "--digits", help="TOTP digits"),
key: Optional[str] = typer.Option(None, "--key", help="New key"),
value: Optional[str] = typer.Option(None, "--value", help="New value"),
) -> None:
"""Modify an existing entry."""
service = _get_entry_service(ctx)
try:
service.modify_entry(
entry_id,
username=username,
url=url,
notes=notes,
label=label,
period=period,
digits=digits,
key=key,
value=value,
)
except ValueError as e:
typer.echo(str(e))
sys.stdout.flush()
raise typer.Exit(code=1)
@app.command("archive")
def entry_archive(ctx: typer.Context, entry_id: int) -> None:
"""Archive an entry."""
service = _get_entry_service(ctx)
service.archive_entry(entry_id)
typer.echo(str(entry_id))
@app.command("unarchive")
def entry_unarchive(ctx: typer.Context, entry_id: int) -> None:
"""Restore an archived entry."""
service = _get_entry_service(ctx)
service.restore_entry(entry_id)
typer.echo(str(entry_id))
@app.command("totp-codes")
def entry_totp_codes(ctx: typer.Context) -> None:
"""Display all current TOTP codes."""
service = _get_entry_service(ctx)
service.display_totp_codes()
@app.command("export-totp")
def entry_export_totp(
ctx: typer.Context, file: str = typer.Option(..., help="Output file")
) -> None:
"""Export all TOTP secrets to a JSON file."""
service = _get_entry_service(ctx)
data = service.export_totp_entries()
Path(file).write_text(json.dumps(data, indent=2))
typer.echo(str(file))
+39
View File
@@ -0,0 +1,39 @@
from __future__ import annotations
import typer
from .common import _get_services, ProfileRemoveRequest, ProfileSwitchRequest
app = typer.Typer(help="Manage seed profiles")
@app.command("list")
def fingerprint_list(ctx: typer.Context) -> None:
"""List available seed profiles."""
_vault, profile_service, _sync = _get_services(ctx)
for fp in profile_service.list_profiles():
typer.echo(fp)
@app.command("add")
def fingerprint_add(ctx: typer.Context) -> None:
"""Create a new seed profile."""
_vault, profile_service, _sync = _get_services(ctx)
profile_service.add_profile()
@app.command("remove")
def fingerprint_remove(ctx: typer.Context, fingerprint: str) -> None:
"""Remove a seed profile."""
_vault, profile_service, _sync = _get_services(ctx)
profile_service.remove_profile(ProfileRemoveRequest(fingerprint=fingerprint))
@app.command("switch")
def fingerprint_switch(ctx: typer.Context, fingerprint: str) -> None:
"""Switch to another seed profile."""
_vault, profile_service, _sync = _get_services(ctx)
password = typer.prompt("Master password", hide_input=True)
profile_service.switch_profile(
ProfileSwitchRequest(fingerprint=fingerprint, password=password)
)
+66
View File
@@ -0,0 +1,66 @@
from __future__ import annotations
import typer
from .common import _get_services, _get_nostr_service
app = typer.Typer(
help="Interact with Nostr relays. See docs/nostr_setup.md for configuration and troubleshooting."
)
@app.command("sync")
def nostr_sync(ctx: typer.Context) -> None:
"""Sync with configured Nostr relays."""
_vault, _profile, sync_service = _get_services(ctx)
model = sync_service.sync()
if model:
typer.echo("Event IDs:")
typer.echo(f"- manifest: {model.manifest_id}")
for cid in model.chunk_ids:
typer.echo(f"- chunk: {cid}")
for did in model.delta_ids:
typer.echo(f"- delta: {did}")
else:
typer.echo("Error: Failed to sync vault")
@app.command("get-pubkey")
def nostr_get_pubkey(ctx: typer.Context) -> None:
"""Display the active profile's npub."""
service = _get_nostr_service(ctx)
npub = service.get_pubkey()
typer.echo(npub)
@app.command("list-relays")
def nostr_list_relays(ctx: typer.Context) -> None:
"""Display configured Nostr relays."""
service = _get_nostr_service(ctx)
relays = service.list_relays()
for i, r in enumerate(relays, 1):
typer.echo(f"{i}: {r}")
@app.command("add-relay")
def nostr_add_relay(ctx: typer.Context, url: str) -> None:
"""Add a relay URL."""
service = _get_nostr_service(ctx)
try:
service.add_relay(url)
except Exception as exc: # pragma: no cover - pass through errors
typer.echo(f"Error: {exc}")
raise typer.Exit(code=1)
typer.echo("Added")
@app.command("remove-relay")
def nostr_remove_relay(ctx: typer.Context, idx: int) -> None:
"""Remove a relay by index (1-based)."""
service = _get_nostr_service(ctx)
try:
service.remove_relay(idx)
except Exception as exc: # pragma: no cover - pass through errors
typer.echo(f"Error: {exc}")
raise typer.Exit(code=1)
typer.echo("Removed")
+72
View File
@@ -0,0 +1,72 @@
from __future__ import annotations
from typing import Optional
import typer
from .common import _get_util_service
app = typer.Typer(help="Utility commands")
@app.command("generate-password")
def generate_password(
ctx: typer.Context,
length: int = 24,
no_special: bool = typer.Option(
False, "--no-special", help="Exclude special characters"
),
allowed_special_chars: Optional[str] = typer.Option(
None, "--allowed-special-chars", help="Explicit set of special characters"
),
special_mode: Optional[str] = typer.Option(
None,
"--special-mode",
help="Special character mode",
),
exclude_ambiguous: bool = typer.Option(
False,
"--exclude-ambiguous",
help="Exclude ambiguous characters",
),
min_uppercase: Optional[int] = typer.Option(None, "--min-uppercase"),
min_lowercase: Optional[int] = typer.Option(None, "--min-lowercase"),
min_digits: Optional[int] = typer.Option(None, "--min-digits"),
min_special: Optional[int] = typer.Option(None, "--min-special"),
) -> None:
"""Generate a strong password."""
service = _get_util_service(ctx)
kwargs = {}
if no_special:
kwargs["include_special_chars"] = False
if allowed_special_chars is not None:
kwargs["allowed_special_chars"] = allowed_special_chars
if special_mode is not None:
kwargs["special_mode"] = special_mode
if exclude_ambiguous:
kwargs["exclude_ambiguous"] = True
if min_uppercase is not None:
kwargs["min_uppercase"] = min_uppercase
if min_lowercase is not None:
kwargs["min_lowercase"] = min_lowercase
if min_digits is not None:
kwargs["min_digits"] = min_digits
if min_special is not None:
kwargs["min_special"] = min_special
password = service.generate_password(length, **kwargs)
typer.echo(password)
@app.command("verify-checksum")
def verify_checksum(ctx: typer.Context) -> None:
"""Verify the SeedPass script checksum."""
service = _get_util_service(ctx)
service.verify_checksum()
@app.command("update-checksum")
def update_checksum(ctx: typer.Context) -> None:
"""Regenerate the script checksum file."""
service = _get_util_service(ctx)
service.update_checksum()
+98
View File
@@ -0,0 +1,98 @@
from __future__ import annotations
import json
from pathlib import Path
from typing import Optional
import typer
from .common import (
_get_services,
ChangePasswordRequest,
UnlockRequest,
BackupParentSeedRequest,
)
app = typer.Typer(help="Manage the entire vault")
@app.command("export")
def vault_export(
ctx: typer.Context, file: str = typer.Option(..., help="Output file")
) -> None:
"""Export the vault profile to an encrypted file."""
vault_service, _profile, _sync = _get_services(ctx)
data = vault_service.export_profile()
Path(file).write_bytes(data)
typer.echo(str(file))
@app.command("import")
def vault_import(
ctx: typer.Context, file: str = typer.Option(..., help="Input file")
) -> None:
"""Import a vault profile from an encrypted file."""
vault_service, _profile, _sync = _get_services(ctx)
data = Path(file).read_bytes()
vault_service.import_profile(data)
typer.echo(str(file))
@app.command("change-password")
def vault_change_password(ctx: typer.Context) -> None:
"""Change the master password used for encryption."""
vault_service, _profile, _sync = _get_services(ctx)
old_pw = typer.prompt("Current password", hide_input=True)
new_pw = typer.prompt("New password", hide_input=True, confirmation_prompt=True)
try:
vault_service.change_password(
ChangePasswordRequest(old_password=old_pw, new_password=new_pw)
)
except Exception as exc: # pragma: no cover - pass through errors
typer.echo(f"Error: {exc}")
raise typer.Exit(code=1)
typer.echo("Password updated")
@app.command("unlock")
def vault_unlock(ctx: typer.Context) -> None:
"""Unlock the vault for the active profile."""
vault_service, _profile, _sync = _get_services(ctx)
password = typer.prompt("Master password", hide_input=True)
try:
resp = vault_service.unlock(UnlockRequest(password=password))
except Exception as exc: # pragma: no cover - pass through errors
typer.echo(f"Error: {exc}")
raise typer.Exit(code=1)
typer.echo(f"Unlocked in {resp.duration:.2f}s")
@app.command("lock")
def vault_lock(ctx: typer.Context) -> None:
"""Lock the vault and clear sensitive data from memory."""
vault_service, _profile, _sync = _get_services(ctx)
vault_service.lock()
typer.echo("locked")
@app.command("stats")
def vault_stats(ctx: typer.Context) -> None:
"""Display statistics about the current seed profile."""
vault_service, _profile, _sync = _get_services(ctx)
stats = vault_service.stats()
typer.echo(json.dumps(stats, indent=2))
@app.command("reveal-parent-seed")
def vault_reveal_parent_seed(
ctx: typer.Context,
file: Optional[str] = typer.Option(
None, "--file", help="Save encrypted seed to this path"
),
) -> None:
"""Display the parent seed and optionally write an encrypted backup file."""
vault_service, _profile, _sync = _get_services(ctx)
password = typer.prompt("Master password", hide_input=True)
vault_service.backup_parent_seed(
BackupParentSeedRequest(path=Path(file) if file else None, password=password)
)
+15 -8
View File
@@ -148,7 +148,9 @@ class VaultService:
"""Restore a profile from ``data`` and sync."""
with self._lock:
decrypted = self._manager.vault.encryption_manager.decrypt_data(data)
decrypted = self._manager.vault.encryption_manager.decrypt_data(
data, context="profile"
)
index = json.loads(decrypted.decode("utf-8"))
self._manager.vault.save_index(index)
self._manager.sync_vault()
@@ -263,13 +265,13 @@ class EntryService:
def list_entries(
self,
sort_by: str = "index",
filter_kind: str | None = None,
filter_kinds: list[str] | None = None,
include_archived: bool = False,
):
with self._lock:
return self._manager.entry_manager.list_entries(
sort_by=sort_by,
filter_kind=filter_kind,
filter_kinds=filter_kinds,
include_archived=include_archived,
)
@@ -303,9 +305,10 @@ class EntryService:
def get_totp_code(self, entry_id: int) -> str:
with self._lock:
return self._manager.entry_manager.get_totp_code(
entry_id, self._manager.parent_seed
key = getattr(self._manager, "KEY_TOTP_DET", None) or getattr(
self._manager, "parent_seed", None
)
return self._manager.entry_manager.get_totp_code(entry_id, key)
def get_totp_secret(self, entry_id: int) -> str:
"""Return the TOTP secret (base32) for the given entry."""
@@ -367,15 +370,18 @@ class EntryService:
secret: str | None = None,
period: int = 30,
digits: int = 6,
deterministic: bool = False,
) -> str:
with self._lock:
key = self._manager.KEY_TOTP_DET if deterministic else None
uri = self._manager.entry_manager.add_totp(
label,
self._manager.parent_seed,
key,
index=index,
secret=secret,
period=period,
digits=digits,
deterministic=deterministic,
)
self._manager.start_background_vault_sync()
return uri
@@ -520,9 +526,10 @@ class EntryService:
def export_totp_entries(self) -> dict:
with self._lock:
return self._manager.entry_manager.export_totp_entries(
self._manager.parent_seed
key = getattr(self._manager, "KEY_TOTP_DET", None) or getattr(
self._manager, "parent_seed", None
)
return self._manager.entry_manager.export_totp_entries(key)
def display_totp_codes(self) -> None:
with self._lock:
+22 -1
View File
@@ -15,7 +15,6 @@ import logging
import os
import shutil
import time
import traceback
from pathlib import Path
from termcolor import colored
@@ -146,6 +145,28 @@ class BackupManager:
)
)
def restore_from_backup(self, backup_path: str) -> None:
"""Restore the index file from a user-specified backup path."""
try:
src = Path(backup_path)
if not src.exists():
logger.error(f"Backup file '{src}' does not exist.")
print(colored(f"Error: Backup file '{src}' does not exist.", "red"))
return
shutil.copy2(src, self.index_file)
os.chmod(self.index_file, 0o600)
logger.info(f"Index file restored from backup '{src}'.")
print(colored(f"[+] Index file restored from backup '{src}'.", "green"))
except Exception as e:
logger.error(
f"Failed to restore from backup '{backup_path}': {e}", exc_info=True
)
print(
colored(
f"Error: Failed to restore from backup '{backup_path}': {e}", "red"
)
)
def list_backups(self) -> None:
try:
backup_files = sorted(
+26 -8
View File
@@ -4,7 +4,7 @@ from __future__ import annotations
import logging
from pathlib import Path
from typing import List, Optional
from typing import List, Optional, TYPE_CHECKING
from utils.seed_prompt import masked_input
@@ -15,6 +15,9 @@ from nostr.client import DEFAULT_RELAYS as DEFAULT_NOSTR_RELAYS
from constants import INACTIVITY_TIMEOUT, MAX_RETRIES, RETRY_DELAY
if TYPE_CHECKING:
from .password_generation import PasswordPolicy
logger = logging.getLogger(__name__)
@@ -41,17 +44,18 @@ class ConfigManager:
logger.info("Config file not found; returning defaults")
return {
"relays": list(DEFAULT_NOSTR_RELAYS),
"offline_mode": False,
"offline_mode": True,
"pin_hash": "",
"password_hash": "",
"inactivity_timeout": INACTIVITY_TIMEOUT,
"kdf_iterations": 50_000,
"kdf_mode": "pbkdf2",
"argon2_time_cost": 2,
"additional_backup_path": "",
"backup_interval": 0,
"secret_mode_enabled": False,
"clipboard_clear_delay": 45,
"quick_unlock": False,
"quick_unlock_enabled": False,
"nostr_max_retries": MAX_RETRIES,
"nostr_retry_delay": float(RETRY_DELAY),
"min_uppercase": 2,
@@ -70,17 +74,18 @@ class ConfigManager:
raise ValueError("Config data must be a dictionary")
# Ensure defaults for missing keys
data.setdefault("relays", list(DEFAULT_NOSTR_RELAYS))
data.setdefault("offline_mode", False)
data.setdefault("offline_mode", True)
data.setdefault("pin_hash", "")
data.setdefault("password_hash", "")
data.setdefault("inactivity_timeout", INACTIVITY_TIMEOUT)
data.setdefault("kdf_iterations", 50_000)
data.setdefault("kdf_mode", "pbkdf2")
data.setdefault("argon2_time_cost", 2)
data.setdefault("additional_backup_path", "")
data.setdefault("backup_interval", 0)
data.setdefault("secret_mode_enabled", False)
data.setdefault("clipboard_clear_delay", 45)
data.setdefault("quick_unlock", False)
data.setdefault("quick_unlock_enabled", data.get("quick_unlock", False))
data.setdefault("nostr_max_retries", MAX_RETRIES)
data.setdefault("nostr_retry_delay", float(RETRY_DELAY))
data.setdefault("min_uppercase", 2)
@@ -196,6 +201,19 @@ class ConfigManager:
config = self.load_config(require_pin=False)
return config.get("kdf_mode", "pbkdf2")
def set_argon2_time_cost(self, time_cost: int) -> None:
"""Persist the Argon2 ``time_cost`` parameter."""
if time_cost <= 0:
raise ValueError("time_cost must be positive")
config = self.load_config(require_pin=False)
config["argon2_time_cost"] = int(time_cost)
self.save_config(config)
def get_argon2_time_cost(self) -> int:
"""Retrieve the Argon2 ``time_cost`` setting."""
config = self.load_config(require_pin=False)
return int(config.get("argon2_time_cost", 2))
def set_additional_backup_path(self, path: Optional[str]) -> None:
"""Persist an optional additional backup path in the config."""
config = self.load_config(require_pin=False)
@@ -228,7 +246,7 @@ class ConfigManager:
def get_offline_mode(self) -> bool:
"""Retrieve the offline mode setting."""
config = self.load_config(require_pin=False)
return bool(config.get("offline_mode", False))
return bool(config.get("offline_mode", True))
def set_clipboard_clear_delay(self, delay: int) -> None:
"""Persist clipboard clear timeout in seconds."""
@@ -320,13 +338,13 @@ class ConfigManager:
def set_quick_unlock(self, enabled: bool) -> None:
"""Persist the quick unlock toggle."""
cfg = self.load_config(require_pin=False)
cfg["quick_unlock"] = bool(enabled)
cfg["quick_unlock_enabled"] = bool(enabled)
self.save_config(cfg)
def get_quick_unlock(self) -> bool:
"""Retrieve whether quick unlock is enabled."""
cfg = self.load_config(require_pin=False)
return bool(cfg.get("quick_unlock", False))
return bool(cfg.get("quick_unlock_enabled", False))
def set_nostr_max_retries(self, retries: int) -> None:
"""Persist the maximum number of Nostr retry attempts."""
+374 -141
View File
@@ -1,12 +1,12 @@
# /src/seedpass.core/encryption.py
import logging
import traceback
import unicodedata
try:
import orjson as json_lib # type: ignore
JSONDecodeError = orjson.JSONDecodeError
JSONDecodeError = json_lib.JSONDecodeError
USE_ORJSON = True
except Exception: # pragma: no cover - fallback for environments without orjson
import json as json_lib
@@ -16,39 +16,41 @@ except Exception: # pragma: no cover - fallback for environments without orjson
import hashlib
import os
import base64
import zlib
from dataclasses import asdict
from pathlib import Path
from typing import Optional
from typing import Optional, Tuple
from cryptography.hazmat.primitives.ciphers.aead import AESGCM
from cryptography.exceptions import InvalidTag
from cryptography.fernet import Fernet, InvalidToken
from termcolor import colored
from utils.file_lock import exclusive_lock
from mnemonic import Mnemonic
from utils.password_prompt import prompt_existing_password
from utils.key_derivation import KdfConfig, CURRENT_KDF_VERSION
from .errors import DecryptionError
# Instantiate the logger
logger = logging.getLogger(__name__)
def get_salt_from_parent_seed_file(file_path: Path) -> bytes:
"""
Reads the salt from the parent seed file if it exists and uses the V3 format.
def _derive_legacy_key_from_password(password: str, iterations: int = 100_000) -> bytes:
"""Derive legacy Fernet key using password only (no fingerprint)."""
normalized = unicodedata.normalize("NFKD", password).strip().encode("utf-8")
key = hashlib.pbkdf2_hmac("sha256", normalized, b"", iterations, dklen=32)
return base64.urlsafe_b64encode(key)
Parameters:
file_path (Path): The path to the parent seed file.
Returns:
bytes: The 16-byte salt if found, otherwise an empty bytes object.
"""
if not file_path.exists():
return b""
try:
with open(file_path, "rb") as f:
header = f.read(3)
if header == b"V3:":
return f.read(16)
except Exception as e:
logger.warning(f"Failed to read salt from {file_path}: {e}")
return b""
class LegacyFormatRequiresMigrationError(Exception):
"""Raised when legacy-encrypted data needs user-guided migration."""
def __init__(self, context: Optional[str] = None) -> None:
msg = (
f"Legacy data detected for {context}" if context else "Legacy data detected"
)
super().__init__(msg)
self.context = context
class EncryptionManager:
@@ -88,46 +90,94 @@ class EncryptionManager:
)
raise
# Track user preference for handling legacy indexes
self._legacy_migrate_flag = True
self.last_migration_performed = False
# Track nonces to detect accidental reuse
self.nonce_crc_table: set[int] = set()
def encrypt_data(self, data: bytes) -> bytes:
"""
(2) Encrypts data using the NEW AES-GCM format, prepending a version
header and the nonce. All new data will be in this format.
Encrypt data using AES-GCM, emitting ``b"V3|" + nonce + ciphertext + tag``.
A fresh 96-bit nonce is generated for each call and tracked via a CRC
table to detect accidental reuse during batch operations.
"""
try:
nonce = os.urandom(12) # 96-bit nonce is recommended for AES-GCM
crc = zlib.crc32(nonce)
if crc in self.nonce_crc_table:
raise ValueError("Nonce reuse detected")
self.nonce_crc_table.add(crc)
ciphertext = self.cipher.encrypt(nonce, data, None)
return b"V2:" + nonce + ciphertext
return b"V3|" + nonce + ciphertext
except Exception as e:
logger.error(f"Failed to encrypt data: {e}", exc_info=True)
raise
def decrypt_data(self, encrypted_data: bytes) -> bytes:
"""
(3) The core migration logic. Tries the new format first, then falls back
to the old one. This is the ONLY place decryption logic should live.
"""
# Try the new V2 format first
if encrypted_data.startswith(b"V2:"):
try:
nonce = encrypted_data[3:15]
ciphertext = encrypted_data[15:]
if len(ciphertext) < 16:
logger.error("AES-GCM payload too short")
raise InvalidToken("AES-GCM payload too short")
return self.cipher.decrypt(nonce, ciphertext, None)
except InvalidTag as e:
logger.error("AES-GCM decryption failed: Invalid authentication tag.")
try:
result = self.fernet.decrypt(encrypted_data[3:])
logger.warning(
"Legacy-format file had incorrect 'V2:' header; decrypted with Fernet"
)
return result
except InvalidToken:
raise InvalidToken("AES-GCM decryption failed.") from e
def decrypt_data(
self, encrypted_data: bytes, context: Optional[str] = None
) -> bytes:
"""Decrypt ``encrypted_data`` handling legacy fallbacks.
# If it's not V2, it must be the legacy Fernet format
else:
Parameters
----------
encrypted_data:
The bytes to decrypt.
context:
Optional string describing what is being decrypted ("seed", "index", etc.)
for clearer error messages.
"""
ctx = f" {context}" if context else ""
try:
# Try the new V3 format first
if encrypted_data.startswith(b"V3|"):
try:
nonce = encrypted_data[3:15]
ciphertext = encrypted_data[15:]
if len(ciphertext) < 16:
logger.error("AES-GCM payload too short")
raise DecryptionError(
f"Failed to decrypt{ctx}: AES-GCM payload too short"
)
return self.cipher.decrypt(nonce, ciphertext, None)
except InvalidTag as e:
logger.error(f"Failed to decrypt{ctx}: invalid key or corrupt file")
raise DecryptionError(
f"Failed to decrypt{ctx}: invalid key or corrupt file"
) from e
# Next try the older V2 format
if encrypted_data.startswith(b"V2:"):
try:
nonce = encrypted_data[3:15]
ciphertext = encrypted_data[15:]
if len(ciphertext) < 16:
logger.error("AES-GCM payload too short")
raise DecryptionError(
f"Failed to decrypt{ctx}: AES-GCM payload too short"
)
return self.cipher.decrypt(nonce, ciphertext, None)
except InvalidTag as e:
logger.debug(
"AES-GCM decryption failed: Invalid authentication tag."
)
try:
result = self.fernet.decrypt(encrypted_data[3:])
logger.warning(
"Legacy-format file had incorrect 'V2:' header; decrypted with Fernet"
)
return result
except InvalidToken:
logger.error(
f"Failed to decrypt{ctx}: invalid key or corrupt file"
)
raise DecryptionError(
f"Failed to decrypt{ctx}: invalid key or corrupt file"
) from e
# If it's neither V3 nor V2, assume legacy Fernet format
logger.warning("Data is in legacy Fernet format. Attempting migration.")
try:
return self.fernet.decrypt(encrypted_data)
@@ -135,136 +185,262 @@ class EncryptionManager:
logger.error(
"Legacy Fernet decryption failed. Vault may be corrupt or key is incorrect."
)
raise InvalidToken(
"Could not decrypt data with any available method."
raise DecryptionError(
f"Failed to decrypt{ctx}: invalid key or corrupt file"
) from e
except DecryptionError as e:
if (
encrypted_data.startswith(b"V3|")
or encrypted_data.startswith(b"V2:")
or not self._legacy_migrate_flag
):
raise
logger.debug(f"Could not decrypt data{ctx}: {e}")
raise LegacyFormatRequiresMigrationError(context) from e
except (InvalidToken, InvalidTag) as e: # pragma: no cover - safety net
raise DecryptionError(
f"Failed to decrypt{ctx}: invalid key or corrupt file"
) from e
def decrypt_legacy(
self, encrypted_data: bytes, password: str, context: Optional[str] = None
) -> bytes:
"""Decrypt ``encrypted_data`` using legacy password-only key derivation."""
ctx = f" {context}" if context else ""
last_exc: Optional[Exception] = None
for iter_count in [50_000, 100_000]:
try:
legacy_key = _derive_legacy_key_from_password(
password, iterations=iter_count
)
legacy_mgr = EncryptionManager(legacy_key, self.fingerprint_dir)
legacy_mgr._legacy_migrate_flag = False
result = legacy_mgr.decrypt_data(encrypted_data, context=context)
try: # record iteration count for future runs
from .vault import Vault
from .config_manager import ConfigManager
cfg_mgr = ConfigManager(
Vault(self, self.fingerprint_dir), self.fingerprint_dir
)
cfg_mgr.set_kdf_iterations(iter_count)
except Exception: # pragma: no cover - best effort
logger.error(
"Failed to record PBKDF2 iteration count in config",
exc_info=True,
)
logger.warning(
"Data decrypted using legacy password-only key derivation."
)
return result
except Exception as e2: # pragma: no cover - try next iteration
last_exc = e2
logger.error(f"Failed legacy decryption attempt: {last_exc}", exc_info=True)
raise DecryptionError(
f"Failed to decrypt{ctx}: invalid key or corrupt file"
) from last_exc
# --- All functions below this point now use the smart `decrypt_data` method ---
def encrypt_parent_seed(self, parent_seed: str, salt: bytes = b"") -> None:
def resolve_relative_path(self, relative_path: Path) -> Path:
"""Resolve ``relative_path`` within ``fingerprint_dir`` and validate it.
Parameters
----------
relative_path:
The user-supplied path relative to ``fingerprint_dir``.
Returns
-------
Path
The normalized absolute path inside ``fingerprint_dir``.
Raises
------
ValueError
If the resulting path is absolute or escapes ``fingerprint_dir``.
"""
candidate = (self.fingerprint_dir / relative_path).resolve()
if not candidate.is_relative_to(self.fingerprint_dir.resolve()):
raise ValueError("Invalid path outside fingerprint directory")
return candidate
def encrypt_parent_seed(
self, parent_seed: str, kdf: Optional[KdfConfig] = None
) -> None:
"""Encrypts and saves the parent seed to 'parent_seed.enc'."""
data = parent_seed.encode("utf-8")
if salt:
# V3 format: V3:<salt(16)><nonce(12)><ciphertext>
try:
nonce = os.urandom(12)
ciphertext = self.cipher.encrypt(nonce, data, None)
encrypted_data = b"V3:" + salt + nonce + ciphertext
except Exception as e:
logger.error(f"Failed to encrypt parent seed with V3 format: {e}", exc_info=True)
raise
else:
# Fallback to V2 (no salt stored) or use encrypt_data
encrypted_data = self.encrypt_data(data) # This creates V2 format
with exclusive_lock(self.parent_seed_file) as fh:
fh.seek(0)
fh.truncate()
fh.write(encrypted_data)
os.chmod(self.parent_seed_file, 0o600)
self.encrypt_and_save_file(data, self.parent_seed_file, kdf=kdf)
logger.info(f"Parent seed encrypted and saved to '{self.parent_seed_file}'.")
def decrypt_parent_seed(self) -> str:
"""Decrypts and returns the parent seed, handling migration."""
with exclusive_lock(self.parent_seed_file) as fh:
fh.seek(0)
encrypted_data = fh.read()
blob = fh.read()
if encrypted_data.startswith(b"V3:"):
# V3 format: V3:<salt(16)><nonce(12)><ciphertext>
try:
# Salt is used for key derivation externally, so we skip it here
nonce = encrypted_data[19:31]
ciphertext = encrypted_data[31:]
if len(ciphertext) < 16:
raise InvalidToken("AES-GCM payload too short")
decrypted_data = self.cipher.decrypt(nonce, ciphertext, None)
return decrypted_data.decode("utf-8").strip()
except InvalidTag as e:
logger.error("AES-GCM decryption failed for V3 parent seed.")
raise InvalidToken("AES-GCM decryption failed.") from e
is_legacy = not encrypted_data.startswith(b"V2:")
decrypted_data = self.decrypt_data(encrypted_data)
kdf, encrypted_data = self._deserialize(blob)
is_legacy = not (
encrypted_data.startswith(b"V3|") or encrypted_data.startswith(b"V2:")
)
decrypted_data = self.decrypt_data(encrypted_data, context="seed")
if is_legacy:
logger.info("Parent seed was in legacy format. Re-encrypting to V2 format.")
self.encrypt_parent_seed(decrypted_data.decode("utf-8").strip())
logger.info("Parent seed was in legacy format. Re-encrypting to V3 format.")
self.encrypt_parent_seed(decrypted_data.decode("utf-8").strip(), kdf=kdf)
return decrypted_data.decode("utf-8").strip()
def encrypt_and_save_file(self, data: bytes, relative_path: Path) -> None:
file_path = self.fingerprint_dir / relative_path
def _serialize(self, kdf: KdfConfig, ciphertext: bytes) -> bytes:
payload = {"kdf": asdict(kdf), "ct": base64.b64encode(ciphertext).decode()}
if USE_ORJSON:
return json_lib.dumps(payload)
return json_lib.dumps(payload, separators=(",", ":")).encode("utf-8")
def _deserialize(self, blob: bytes) -> Tuple[KdfConfig, bytes]:
"""Return ``(KdfConfig, ciphertext)`` from serialized *blob*.
Legacy files stored the raw ciphertext without a JSON wrapper. If
decoding the wrapper fails, treat ``blob`` as the ciphertext and return
a default HKDF configuration.
"""
try:
if USE_ORJSON:
obj = json_lib.loads(blob)
else:
obj = json_lib.loads(blob.decode("utf-8"))
kdf = KdfConfig(**obj.get("kdf", {}))
ct_b64 = obj.get("ct", "")
ciphertext = base64.b64decode(ct_b64)
if ciphertext:
return kdf, ciphertext
except Exception: # pragma: no cover - fall back to legacy path
pass
# Legacy format: ``blob`` already contains the ciphertext
return (
KdfConfig(name="hkdf", version=CURRENT_KDF_VERSION, params={}, salt_b64=""),
blob,
)
def encrypt_and_save_file(
self, data: bytes, relative_path: Path, *, kdf: Optional[KdfConfig] = None
) -> None:
if kdf is None:
kdf = KdfConfig()
file_path = self.resolve_relative_path(relative_path)
file_path.parent.mkdir(parents=True, exist_ok=True)
encrypted_data = self.encrypt_data(data)
payload = self._serialize(kdf, encrypted_data)
with exclusive_lock(file_path) as fh:
fh.seek(0)
fh.truncate()
fh.write(encrypted_data)
fh.write(payload)
fh.flush()
os.fsync(fh.fileno())
os.chmod(file_path, 0o600)
def decrypt_file(self, relative_path: Path) -> bytes:
file_path = self.fingerprint_dir / relative_path
file_path = self.resolve_relative_path(relative_path)
with exclusive_lock(file_path) as fh:
fh.seek(0)
encrypted_data = fh.read()
return self.decrypt_data(encrypted_data)
blob = fh.read()
_, encrypted_data = self._deserialize(blob)
return self.decrypt_data(encrypted_data, context=str(relative_path))
def save_json_data(self, data: dict, relative_path: Optional[Path] = None) -> None:
def get_file_kdf(self, relative_path: Path) -> KdfConfig:
file_path = self.resolve_relative_path(relative_path)
with exclusive_lock(file_path) as fh:
fh.seek(0)
blob = fh.read()
kdf, _ = self._deserialize(blob)
return kdf
def save_json_data(
self,
data: dict,
relative_path: Optional[Path] = None,
*,
kdf: Optional[KdfConfig] = None,
) -> None:
if relative_path is None:
relative_path = Path("seedpass_entries_db.json.enc")
if USE_ORJSON:
json_data = json_lib.dumps(data)
else:
json_data = json_lib.dumps(data, separators=(",", ":")).encode("utf-8")
self.encrypt_and_save_file(json_data, relative_path)
self.encrypt_and_save_file(json_data, relative_path, kdf=kdf)
logger.debug(f"JSON data encrypted and saved to '{relative_path}'.")
def load_json_data(self, relative_path: Optional[Path] = None) -> dict:
def load_json_data(
self, relative_path: Optional[Path] = None, *, return_kdf: bool = False
) -> dict | Tuple[dict, KdfConfig]:
"""
Loads and decrypts JSON data, automatically migrating and re-saving
if it's in the legacy format.
"""
if relative_path is None:
relative_path = Path("seedpass_entries_db.json.enc")
file_path = self.fingerprint_dir / relative_path
file_path = self.resolve_relative_path(relative_path)
if not file_path.exists():
return {"entries": {}}
empty: dict = {"entries": {}}
if return_kdf:
return empty, KdfConfig(
name="hkdf", version=CURRENT_KDF_VERSION, params={}, salt_b64=""
)
return empty
with exclusive_lock(file_path) as fh:
fh.seek(0)
encrypted_data = fh.read()
blob = fh.read()
is_legacy = not encrypted_data.startswith(b"V2:")
kdf, encrypted_data = self._deserialize(blob)
is_legacy = not (
encrypted_data.startswith(b"V3|") or encrypted_data.startswith(b"V2:")
)
self.last_migration_performed = False
try:
decrypted_data = self.decrypt_data(encrypted_data)
decrypted_data = self.decrypt_data(
encrypted_data, context=str(relative_path)
)
if USE_ORJSON:
data = json_lib.loads(decrypted_data)
else:
data = json_lib.loads(decrypted_data.decode("utf-8"))
# If it was a legacy file, re-save it in the new format now
if is_legacy:
if is_legacy and self._legacy_migrate_flag:
logger.info(f"Migrating and re-saving legacy vault file: {file_path}")
self.save_json_data(data, relative_path)
self.save_json_data(data, relative_path, kdf=kdf)
self.update_checksum(relative_path)
self.last_migration_performed = True
if return_kdf:
return data, kdf
return data
except (InvalidToken, InvalidTag, JSONDecodeError) as e:
logger.error(
f"FATAL: Could not decrypt or parse data from {file_path}: {e}",
exc_info=True,
)
except DecryptionError as e:
msg = f"Failed to decrypt or parse data from {file_path}: {e}"
logger.error(msg)
raise
except (InvalidToken, InvalidTag) as e: # pragma: no cover - legacy safety
msg = f"Failed to decrypt or parse data from {file_path}: {e}"
logger.error(msg)
raise DecryptionError(
f"Failed to decrypt {file_path}: invalid key or corrupt file"
) from e
except JSONDecodeError as e:
msg = f"Failed to parse JSON data from {file_path}: {e}"
logger.error(msg)
raise
def get_encrypted_index(self) -> Optional[bytes]:
relative_path = Path("seedpass_entries_db.json.enc")
file_path = self.fingerprint_dir / relative_path
file_path = self.resolve_relative_path(relative_path)
if not file_path.exists():
return None
with exclusive_lock(file_path) as fh:
@@ -293,13 +469,18 @@ class EncryptionManager:
"""
if relative_path is None:
relative_path = Path("seedpass_entries_db.json.enc")
try:
decrypted_data = self.decrypt_data(encrypted_data)
kdf, ciphertext = self._deserialize(encrypted_data)
is_legacy = not (ciphertext.startswith(b"V3|") or ciphertext.startswith(b"V2:"))
self.last_migration_performed = False
def _process(decrypted: bytes) -> dict:
if USE_ORJSON:
data = json_lib.loads(decrypted_data)
data = json_lib.loads(decrypted)
else:
data = json_lib.loads(decrypted_data.decode("utf-8"))
if merge and (self.fingerprint_dir / relative_path).exists():
data = json_lib.loads(decrypted.decode("utf-8"))
existing_file = self.resolve_relative_path(relative_path)
if merge and existing_file.exists():
current = self.load_json_data(relative_path)
current_entries = current.get("entries", {})
for idx, entry in data.get("entries", {}).items():
@@ -313,11 +494,53 @@ class EncryptionManager:
current.get("schema_version", 0), data.get("schema_version", 0)
)
data = current
self.save_json_data(data, relative_path) # This always saves in V2 format
return data
try:
decrypted_data = self.decrypt_data(ciphertext, context=str(relative_path))
data = _process(decrypted_data)
self.save_json_data(data, relative_path, kdf=kdf)
self.update_checksum(relative_path)
logger.info("Index file from Nostr was processed and saved successfully.")
print(colored("Index file updated from Nostr successfully.", "green"))
self.last_migration_performed = is_legacy
return True
except (DecryptionError, LegacyFormatRequiresMigrationError):
try:
password = prompt_existing_password(
"Enter your master password for legacy decryption: "
)
decrypted_data = self.decrypt_legacy(
ciphertext, password, context=str(relative_path)
)
data = _process(decrypted_data)
self.save_json_data(data, relative_path, kdf=kdf)
self.update_checksum(relative_path)
logger.warning(
"Index decrypted using legacy password-only key derivation."
)
print(
colored(
"Warning: index decrypted with legacy key; it will be re-encrypted.",
"yellow",
)
)
self.last_migration_performed = True
return True
except Exception as e2:
if strict:
logger.error(
f"Failed legacy decryption attempt: {e2}",
exc_info=True,
)
print(
colored(
f"Error: Failed to decrypt and save data from Nostr: {e2}",
"red",
)
)
raise
logger.warning(f"Failed to decrypt index from Nostr: {e2}")
return False
except Exception as e: # pragma: no cover - error handling
if strict:
logger.error(
@@ -338,8 +561,7 @@ class EncryptionManager:
"""Updates the checksum file for the specified file."""
if relative_path is None:
relative_path = Path("seedpass_entries_db.json.enc")
file_path = self.fingerprint_dir / relative_path
file_path = self.resolve_relative_path(relative_path)
if not file_path.exists():
return
@@ -348,7 +570,22 @@ class EncryptionManager:
fh.seek(0)
encrypted_bytes = fh.read()
checksum = hashlib.sha256(encrypted_bytes).hexdigest()
checksum_file = file_path.parent / f"{file_path.stem}_checksum.txt"
# Build checksum path by stripping both `.json` and `.enc`
checksum_base = file_path.with_suffix("").with_suffix("")
checksum_file = checksum_base.parent / f"{checksum_base.name}_checksum.txt"
# Remove legacy checksum file if present
legacy_checksum = file_path.parent / f"{file_path.stem}_checksum.txt"
if legacy_checksum != checksum_file and legacy_checksum.exists():
try:
legacy_checksum.unlink()
except Exception:
logger.warning(
f"Could not remove legacy checksum file '{legacy_checksum}'",
exc_info=True,
)
with exclusive_lock(checksum_file) as fh:
fh.seek(0)
fh.truncate()
@@ -363,25 +600,21 @@ class EncryptionManager:
)
raise
# ... validate_seed and derive_seed_from_mnemonic can remain the same ...
def validate_seed(self, seed_phrase: str) -> bool:
def validate_seed(self, seed_phrase: str) -> tuple[bool, Optional[str]]:
"""Validate a BIP-39 mnemonic.
Returns a tuple of ``(is_valid, error_message)`` where ``error_message``
is ``None`` when the mnemonic is valid.
"""
try:
words = seed_phrase.split()
if len(words) != 12:
logger.error("Seed phrase does not contain exactly 12 words.")
print(
colored(
"Error: Seed phrase must contain exactly 12 words.",
"red",
)
)
return False
logger.debug("Seed phrase validated successfully.")
return True
if Mnemonic("english").check(seed_phrase):
logger.debug("Seed phrase validated successfully.")
return True, None
logger.error("Seed phrase failed BIP-39 validation.")
return False, "Invalid seed phrase."
except Exception as e:
logging.error(f"Error validating seed phrase: {e}", exc_info=True)
print(colored(f"Error: Failed to validate seed phrase: {e}", "red"))
return False
logger.error(f"Error validating seed phrase: {e}", exc_info=True)
return False, f"Failed to validate seed phrase: {e}"
def derive_seed_from_mnemonic(self, mnemonic: str, passphrase: str = "") -> bytes:
try:
+83 -54
View File
@@ -15,9 +15,16 @@ completely deterministic passwords from a BIP-85 seed, ensuring that passwords a
the same way every time. Salts would break this functionality and are not suitable for this software.
"""
try:
import orjson as json_lib # type: ignore
USE_ORJSON = True
except Exception: # pragma: no cover - fallback when orjson is missing
import json as json_lib
USE_ORJSON = False
import logging
import hashlib
import sys
import shutil
import time
from typing import Optional, Tuple, Dict, Any, List
@@ -25,10 +32,11 @@ from pathlib import Path
from termcolor import colored
from .migrations import LATEST_VERSION
from .entry_types import EntryType
from .totp import TotpManager
from .entry_types import EntryType, ALL_ENTRY_TYPES
from .totp import TotpManager, random_totp_secret
from utils.fingerprint import generate_fingerprint
from utils.checksum import canonical_json_dumps
from utils.atomic_write import atomic_write
from utils.key_validation import (
validate_totp_secret,
validate_ssh_key_pair,
@@ -39,6 +47,7 @@ from utils.key_validation import (
from .vault import Vault
from .backup import BackupManager
from .errors import SeedPassError
# Instantiate the logger
logger = logging.getLogger(__name__)
@@ -138,7 +147,7 @@ class EntryManager:
except Exception as e:
logger.error(f"Error determining next index: {e}", exc_info=True)
print(colored(f"Error determining next index: {e}", "red"))
sys.exit(1)
raise SeedPassError(f"Error determining next index: {e}") from e
def add_entry(
self,
@@ -212,7 +221,9 @@ class EntryManager:
data["entries"][str(index)] = entry
logger.debug(f"Added entry at index {index}: {data['entries'][str(index)]}")
logger.debug(
f"Added entry at index {index} with label '{entry.get('label', '')}'."
)
self._save_index(data)
self.update_checksum()
@@ -226,7 +237,7 @@ class EntryManager:
except Exception as e:
logger.error(f"Failed to add entry: {e}", exc_info=True)
print(colored(f"Error: Failed to add entry: {e}", "red"))
sys.exit(1)
raise SeedPassError(f"Failed to add entry: {e}") from e
def get_next_totp_index(self) -> int:
"""Return the next available derivation index for TOTP secrets."""
@@ -245,7 +256,7 @@ class EntryManager:
def add_totp(
self,
label: str,
parent_seed: str,
parent_seed: str | bytes | None = None,
*,
archived: bool = False,
secret: str | None = None,
@@ -254,13 +265,16 @@ class EntryManager:
digits: int = 6,
notes: str = "",
tags: list[str] | None = None,
deterministic: bool = False,
) -> str:
"""Add a new TOTP entry and return the provisioning URI."""
entry_id = self.get_next_index()
data = self._load_index()
data.setdefault("entries", {})
if secret is None:
if deterministic:
if parent_seed is None:
raise ValueError("Seed required for deterministic TOTP")
if index is None:
index = self.get_next_totp_index()
secret = TotpManager.derive_secret(parent_seed, index)
@@ -277,8 +291,11 @@ class EntryManager:
"archived": archived,
"notes": notes,
"tags": tags or [],
"deterministic": True,
}
else:
if secret is None:
secret = random_totp_secret()
if not validate_totp_secret(secret):
raise ValueError("Invalid TOTP secret")
entry = {
@@ -292,6 +309,7 @@ class EntryManager:
"archived": archived,
"notes": notes,
"tags": tags or [],
"deterministic": False,
}
data["entries"][str(entry_id)] = entry
@@ -449,7 +467,7 @@ class EntryManager:
seed_bytes = Bip39SeedGenerator(parent_seed).Generate()
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())
npub = Keys.hex_to_bech32(keys.public_key_hex(), "npub")
nsec = Keys.hex_to_bech32(keys.private_key_hex(), "nsec")
@@ -527,7 +545,7 @@ class EntryManager:
bip85 = BIP85(seed_bytes)
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())
npub = Keys.hex_to_bech32(keys.public_key_hex(), "npub")
nsec = Keys.hex_to_bech32(keys.private_key_hex(), "nsec")
@@ -676,12 +694,12 @@ class EntryManager:
seed_index = int(entry.get("index", index))
return derive_seed_phrase(bip85, seed_index, words)
def get_totp_secret(self, index: int, parent_seed: str | None = None) -> str:
"""Return the TOTP secret for the specified entry.
If the entry uses an imported secret, it is returned directly.
If the entry uses a derived secret, it is derived from the parent seed.
"""
def get_totp_secret(
self,
index: int,
parent_seed: str | bytes | None = None,
) -> str:
"""Return the TOTP secret for the specified entry."""
entry = self.retrieve_entry(index)
etype = entry.get("type") if entry else None
kind = entry.get("kind") if entry else None
@@ -689,18 +707,18 @@ class EntryManager:
etype != EntryType.TOTP.value and kind != EntryType.TOTP.value
):
raise ValueError("Entry is not a TOTP entry")
if "secret" in entry:
return entry["secret"]
if parent_seed is None:
raise ValueError("Seed required for derived TOTP")
totp_index = int(entry.get("index", 0))
return TotpManager.derive_secret(parent_seed, totp_index)
if entry.get("deterministic", False) or "secret" not in entry:
if parent_seed is None:
raise ValueError("Seed required for derived TOTP")
totp_index = int(entry.get("index", 0))
return TotpManager.derive_secret(parent_seed, totp_index)
return entry["secret"]
def get_totp_code(
self, index: int, parent_seed: str | None = None, timestamp: int | None = None
self,
index: int,
parent_seed: str | bytes | None = None,
timestamp: int | None = None,
) -> str:
"""Return the current TOTP code for the specified entry."""
entry = self.retrieve_entry(index)
@@ -710,12 +728,12 @@ class EntryManager:
etype != EntryType.TOTP.value and kind != EntryType.TOTP.value
):
raise ValueError("Entry is not a TOTP entry")
if "secret" in entry:
return TotpManager.current_code_from_secret(entry["secret"], timestamp)
if parent_seed is None:
raise ValueError("Seed required for derived TOTP")
totp_index = int(entry.get("index", 0))
return TotpManager.current_code(parent_seed, totp_index, timestamp)
if entry.get("deterministic", False) or "secret" not in entry:
if parent_seed is None:
raise ValueError("Seed required for derived TOTP")
totp_index = int(entry.get("index", 0))
return TotpManager.current_code(parent_seed, totp_index, timestamp)
return TotpManager.current_code_from_secret(entry["secret"], timestamp)
def get_totp_time_remaining(self, index: int) -> int:
"""Return seconds remaining in the TOTP period for the given entry."""
@@ -730,7 +748,9 @@ class EntryManager:
period = int(entry.get("period", 30))
return TotpManager.time_remaining(period)
def export_totp_entries(self, parent_seed: str) -> dict[str, list[dict[str, Any]]]:
def export_totp_entries(
self, parent_seed: str | bytes | None
) -> dict[str, list[dict[str, Any]]]:
"""Return all TOTP secrets and metadata for external use."""
data = self._load_index()
entries = data.get("entries", {})
@@ -742,11 +762,13 @@ class EntryManager:
label = entry.get("label", "")
period = int(entry.get("period", 30))
digits = int(entry.get("digits", 6))
if "secret" in entry:
secret = entry["secret"]
else:
if entry.get("deterministic", False) or "secret" not in entry:
if parent_seed is None:
raise ValueError("Seed required for deterministic TOTP export")
idx = int(entry.get("index", 0))
secret = TotpManager.derive_secret(parent_seed, idx)
else:
secret = entry["secret"]
uri = TotpManager.make_otpauth_uri(label, secret, period, digits)
exported.append(
{
@@ -793,7 +815,9 @@ class EntryManager:
EntryType.MANAGED_ACCOUNT.value,
):
entry.setdefault("custom_fields", [])
logger.debug(f"Retrieved entry at index {index}: {entry}")
logger.debug(
f"Retrieved entry at index {index} with label '{entry.get('label', '')}'."
)
clean = {k: v for k, v in entry.items() if k != "modified_ts"}
return clean
else:
@@ -1023,13 +1047,11 @@ class EntryManager:
if custom_fields is not None:
entry["custom_fields"] = custom_fields
logger.debug(
f"Updated custom fields for index {index}: {custom_fields}"
)
logger.debug(f"Updated custom fields for index {index}.")
if tags is not None:
entry["tags"] = tags
logger.debug(f"Updated tags for index {index}: {tags}")
logger.debug(f"Updated tags for index {index}.")
policy_updates: dict[str, Any] = {}
if include_special_chars is not None:
@@ -1056,7 +1078,9 @@ class EntryManager:
entry["modified_ts"] = int(time.time())
data["entries"][str(index)] = entry
logger.debug(f"Modified entry at index {index}: {entry}")
logger.debug(
f"Modified entry at index {index} with label '{entry.get('label', '')}'."
)
self._save_index(data)
self.update_checksum()
@@ -1085,7 +1109,7 @@ class EntryManager:
def list_entries(
self,
sort_by: str = "index",
filter_kind: str | None = None,
filter_kinds: list[str] | None = None,
*,
include_archived: bool = False,
verbose: bool = True,
@@ -1097,8 +1121,9 @@ class EntryManager:
sort_by:
Field to sort by. Supported values are ``"index"``, ``"label"`` and
``"updated"``.
filter_kind:
Optional entry kind to restrict the results.
filter_kinds:
Optional list of entry kinds to restrict the results. Defaults to
``ALL_ENTRY_TYPES``.
Archived entries are omitted unless ``include_archived`` is ``True``.
"""
@@ -1127,12 +1152,14 @@ class EntryManager:
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]]] = []
for idx_str, entry in sorted_items:
if (
filter_kind is not None
and entry.get("type", entry.get("kind", EntryType.PASSWORD.value))
!= filter_kind
entry.get("type", entry.get("kind", EntryType.PASSWORD.value))
not in filter_kinds
):
continue
if not include_archived and entry.get(
@@ -1326,8 +1353,7 @@ class EntryManager:
# The checksum file path already includes the fingerprint directory
checksum_path = self.checksum_file
with open(checksum_path, "w") as f:
f.write(checksum)
atomic_write(checksum_path, lambda f: f.write(checksum))
logger.debug(f"Checksum updated and written to '{checksum_path}'.")
print(colored("[+] Checksum updated successfully.", "green"))
@@ -1381,7 +1407,7 @@ class EntryManager:
def list_all_entries(
self,
sort_by: str = "index",
filter_kind: str | None = None,
filter_kinds: list[str] | None = None,
*,
include_archived: bool = False,
) -> None:
@@ -1389,7 +1415,7 @@ class EntryManager:
try:
entries = self.list_entries(
sort_by=sort_by,
filter_kind=filter_kind,
filter_kinds=filter_kinds,
include_archived=include_archived,
)
if not entries:
@@ -1413,7 +1439,7 @@ class EntryManager:
def get_entry_summaries(
self,
filter_kind: str | None = None,
filter_kinds: list[str] | None = None,
*,
include_archived: bool = False,
) -> list[tuple[int, str, str]]:
@@ -1422,10 +1448,13 @@ class EntryManager:
data = self._load_index()
entries_data = data.get("entries", {})
if filter_kinds is None:
filter_kinds = ALL_ENTRY_TYPES
summaries: list[tuple[int, str, str]] = []
for idx_str, entry in entries_data.items():
etype = entry.get("type", entry.get("kind", EntryType.PASSWORD.value))
if filter_kind and etype != filter_kind:
if etype not in filter_kinds:
continue
if not include_archived and entry.get(
"archived", entry.get("blacklisted", False)
+242
View File
@@ -0,0 +1,242 @@
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:
while True:
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"
)
)
continue
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",
)
)
continue
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()
while True:
mode = input("Choose mode: [Q]uick or [A]dvanced? ").strip().lower()
if not mode:
return
if mode.startswith("q") or mode.startswith("a"):
break
print(
colored(
"Invalid mode. Enter 'q' for quick or 'a' for advanced.", "red"
)
)
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()
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()
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()
+4
View File
@@ -15,3 +15,7 @@ class EntryType(str, Enum):
NOSTR = "nostr"
KEY_VALUE = "key_value"
MANAGED_ACCOUNT = "managed_account"
# List of all entry type values for convenience
ALL_ENTRY_TYPES = [e.value for e in EntryType]
+30
View File
@@ -0,0 +1,30 @@
"""Custom exceptions for SeedPass core modules.
This module defines :class:`SeedPassError`, a base exception used across the
core modules. Library code should raise this error instead of terminating the
process with ``sys.exit`` so that callers can handle failures gracefully.
When raised inside the CLI, :class:`SeedPassError` behaves like a Click
exception, displaying a friendly message and exiting with code ``1``.
"""
from click import ClickException
from cryptography.fernet import InvalidToken
class SeedPassError(ClickException):
"""Base exception for SeedPass-related errors."""
def __init__(self, message: str):
super().__init__(message)
class DecryptionError(InvalidToken, SeedPassError):
"""Raised when encrypted data cannot be decrypted.
Subclasses :class:`cryptography.fernet.InvalidToken` so callers expecting
the cryptography exception continue to work.
"""
__all__ = ["SeedPassError", "DecryptionError"]
File diff suppressed because it is too large. Load diff
+212
View File
@@ -0,0 +1,212 @@
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
from .totp import TotpManager
import seedpass.core.manager as manager_module
from utils.color_scheme import color_text
from utils.terminal_utils import clear_header_with_notification
from utils.logging_utils import pause_logging_for_ui
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
@pause_logging_for_ui
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:
if filter_kinds is None:
print(colored("No active entries found.", "yellow"))
else:
entry_label = filter_kinds[0].replace("_", " ").title()
print(
colored(
f"No active {entry_label} entries found.",
"yellow",
)
)
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"))
@pause_logging_for_ui
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())
totp_secrets: dict[int, str] = {}
key = getattr(pm, "KEY_TOTP_DET", None) or getattr(pm, "parent_seed", None)
for _, idx, _, _ in totp_list:
try:
totp_secrets[idx] = pm.entry_manager.get_totp_secret(idx, key)
except Exception:
pass
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:
secret = totp_secrets.get(idx)
code = (
TotpManager.current_code_from_secret(secret)
if secret
else "ERROR"
)
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:
secret = totp_secrets.get(idx)
code = (
TotpManager.current_code_from_secret(secret)
if secret
else "ERROR"
)
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"))
+77 -55
View File
@@ -11,14 +11,18 @@ Ensure that all dependencies are installed and properly configured in your envir
Never ever ever use Random Salt. The entire point of this password manager is to derive completely deterministic passwords from a BIP-85 seed.
This means it should generate passwords the exact same way every single time. Salts would break this functionality and is not appropriate for this software's use case.
To keep behaviour stable across Python versions, the shuffling logic uses an
HMAC-SHA256-based Fisher–Yates shuffle instead of ``random.Random``. The HMAC
is keyed with the derived password bytes, providing deterministic yet
cryptographically strong pseudo-randomness without relying on Python's
non-stable random implementation.
"""
import os
import logging
import hashlib
import string
import random
import traceback
import hmac
import base64
from typing import Optional
from dataclasses import dataclass
@@ -109,10 +113,12 @@ class PasswordGenerator:
self.bip85 = bip85
self.policy = policy or PasswordPolicy()
# Derive seed bytes from parent_seed using BIP39 (handled by EncryptionManager)
self.seed_bytes = self.encryption_manager.derive_seed_from_mnemonic(
self.parent_seed
)
if isinstance(parent_seed, (bytes, bytearray)):
self.seed_bytes = bytes(parent_seed)
else:
self.seed_bytes = self.encryption_manager.derive_seed_from_mnemonic(
self.parent_seed
)
logger.debug("PasswordGenerator initialized successfully.")
except Exception as e:
@@ -122,8 +128,8 @@ class PasswordGenerator:
def _derive_password_entropy(self, index: int) -> bytes:
"""Derive deterministic entropy for password generation."""
entropy = self.bip85.derive_entropy(index=index, bytes_len=64, app_no=32)
logger.debug(f"Derived entropy: {entropy.hex()}")
entropy = self.bip85.derive_entropy(index=index, entropy_bytes=64, app_no=32)
logger.debug("Entropy derived for password generation.")
hkdf = HKDF(
algorithm=hashes.SHA256(),
@@ -133,26 +139,43 @@ class PasswordGenerator:
backend=default_backend(),
)
hkdf_derived = hkdf.derive(entropy)
logger.debug(f"Derived key using HKDF: {hkdf_derived.hex()}")
logger.debug("Derived key using HKDF.")
dk = hashlib.pbkdf2_hmac("sha256", entropy, b"", 100000)
logger.debug(f"Derived key using PBKDF2: {dk.hex()}")
logger.debug("Derived key using PBKDF2.")
return dk
def _map_entropy_to_chars(self, dk: bytes, alphabet: str) -> str:
"""Map derived bytes to characters from the provided alphabet."""
password = "".join(alphabet[byte % len(alphabet)] for byte in dk)
logger.debug(f"Password after mapping to all allowed characters: {password}")
logger.debug("Mapped entropy to allowed characters.")
return password
def _fisher_yates_hmac(self, items: list[str], key: bytes) -> list[str]:
"""Shuffle ``items`` in a deterministic yet cryptographically sound manner.
A Fisher–Yates shuffle is driven by an HMAC-SHA256 based
pseudo-random number generator seeded with ``key``. Unlike
:class:`random.Random`, this approach is stable across Python
versions while still deriving all of its entropy from ``key``.
"""
counter = 0
for i in range(len(items) - 1, 0, -1):
msg = counter.to_bytes(4, "big")
digest = hmac.new(key, msg, hashlib.sha256).digest()
j = int.from_bytes(digest, "big") % (i + 1)
items[i], items[j] = items[j], items[i]
counter += 1
return items
def _shuffle_deterministically(self, password: str, dk: bytes) -> str:
"""Deterministically shuffle characters using derived bytes."""
shuffle_seed = int.from_bytes(dk, "big")
rng = random.Random(shuffle_seed)
"""Deterministically shuffle characters using an HMAC-based PRNG."""
password_chars = list(password)
rng.shuffle(password_chars)
shuffled = "".join(password_chars)
logger.debug("Shuffled password deterministically.")
shuffled_chars = self._fisher_yates_hmac(password_chars, dk)
shuffled = "".join(shuffled_chars)
logger.debug("Shuffled password deterministically using HMAC-Fisher-Yates.")
return shuffled
def generate_password(
@@ -226,7 +249,7 @@ class PasswordGenerator:
extra = self._map_entropy_to_chars(dk, all_allowed)
password += extra
password = self._shuffle_deterministically(password, dk)
logger.debug(f"Extended password: {password}")
logger.debug("Extended password to meet length requirement.")
# Trim the password to the desired length and enforce complexity on
# the final result. Complexity enforcement is repeated here because
@@ -239,7 +262,7 @@ class PasswordGenerator:
)
password = self._shuffle_deterministically(password, dk)
logger.debug(
f"Final password (trimmed to {length} chars with complexity enforced): {password}"
f"Generated final password of length {length} with complexity enforced."
)
return password
@@ -311,34 +334,28 @@ class PasswordGenerator:
index = get_dk_value() % len(password_chars)
char = uppercase[get_dk_value() % len(uppercase)]
password_chars[index] = char
logger.debug(
f"Added uppercase letter '{char}' at position {index}."
)
logger.debug(f"Added uppercase letter at position {index}.")
if current_lower < min_lower:
for _ in range(min_lower - current_lower):
index = get_dk_value() % len(password_chars)
char = lowercase[get_dk_value() % len(lowercase)]
password_chars[index] = char
logger.debug(
f"Added lowercase letter '{char}' at position {index}."
)
logger.debug(f"Added lowercase letter at position {index}.")
if current_digits < min_digits:
for _ in range(min_digits - current_digits):
index = get_dk_value() % len(password_chars)
char = digits[get_dk_value() % len(digits)]
password_chars[index] = char
logger.debug(f"Added digit '{char}' at position {index}.")
logger.debug(f"Added digit at position {index}.")
if special and current_special < min_special:
for _ in range(min_special - current_special):
index = get_dk_value() % len(password_chars)
char = special[get_dk_value() % len(special)]
password_chars[index] = char
logger.debug(
f"Added special character '{char}' at position {index}."
)
logger.debug(f"Added special character at position {index}.")
# Additional deterministic inclusion of symbols to increase score
if special:
@@ -352,9 +369,7 @@ class PasswordGenerator:
index = get_dk_value() % len(password_chars)
char = special[get_dk_value() % len(special)]
password_chars[index] = char
logger.debug(
f"Added additional symbol '{char}' at position {index}."
)
logger.debug(f"Added additional symbol at position {index}.")
# Ensure balanced distribution by assigning different character types to specific segments
# Example: Divide password into segments and assign different types
@@ -372,19 +387,15 @@ class PasswordGenerator:
if i == 0 and password_chars[j] not in uppercase:
char = uppercase[get_dk_value() % len(uppercase)]
password_chars[j] = char
logger.debug(
f"Assigned uppercase letter '{char}' to position {j}."
)
logger.debug(f"Assigned uppercase letter to position {j}.")
elif i == 1 and password_chars[j] not in lowercase:
char = lowercase[get_dk_value() % len(lowercase)]
password_chars[j] = char
logger.debug(
f"Assigned lowercase letter '{char}' to position {j}."
)
logger.debug(f"Assigned lowercase letter to position {j}.")
elif i == 2 and password_chars[j] not in digits:
char = digits[get_dk_value() % len(digits)]
password_chars[j] = char
logger.debug(f"Assigned digit '{char}' to position {j}.")
logger.debug(f"Assigned digit to position {j}.")
elif (
special
and i == len(char_types) - 1
@@ -392,17 +403,18 @@ class PasswordGenerator:
):
char = special[get_dk_value() % len(special)]
password_chars[j] = char
logger.debug(
f"Assigned special character '{char}' to position {j}."
)
logger.debug(f"Assigned special character to position {j}.")
# Shuffle again to distribute the characters more evenly
shuffle_seed = (
int.from_bytes(dk, "big") + dk_index
) # Modify seed to vary shuffle
rng = random.Random(shuffle_seed)
rng.shuffle(password_chars)
logger.debug(f"Shuffled password characters for balanced distribution.")
# Shuffle again to distribute the characters more evenly. The key is
# tweaked with the current ``dk_index`` so that each call produces a
# unique but deterministic ordering.
shuffle_key = hmac.new(
dk, dk_index.to_bytes(4, "big"), hashlib.sha256
).digest()
password_chars = self._fisher_yates_hmac(password_chars, shuffle_key)
logger.debug(
"Shuffled password characters for balanced distribution using HMAC-Fisher-Yates."
)
# Final counts after modifications
final_upper = sum(1 for c in password_chars if c in uppercase)
@@ -423,7 +435,7 @@ class PasswordGenerator:
def derive_ssh_key(bip85: BIP85, idx: int) -> bytes:
"""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]:
@@ -457,7 +469,13 @@ def derive_seed_phrase(bip85: BIP85, idx: int, words: int = 24) -> str:
def derive_pgp_key(
bip85: BIP85, idx: int, key_type: str = "ed25519", user_id: str = ""
) -> tuple[str, str]:
"""Derive a deterministic PGP private key and return it with its fingerprint."""
"""Derive a deterministic PGP private key and return it with its fingerprint.
For RSA keys the randomness required during key generation is provided by
an HMAC-SHA256 based deterministic generator seeded from the BIP-85
entropy. This avoids use of Python's ``random`` module while ensuring the
output remains stable across Python versions.
"""
from pgpy import PGPKey, PGPUID
from pgpy.packet.packets import PrivKeyV4
@@ -483,20 +501,24 @@ def derive_pgp_key(
import hashlib
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)
if key_type.lower() == "rsa":
class DRNG:
"""HMAC-SHA256 based deterministic random generator."""
def __init__(self, seed: bytes) -> None:
self.seed = seed
self.key = seed
self.counter = 0
def __call__(self, n: int) -> bytes: # pragma: no cover - deterministic
out = b""
while len(out) < n:
self.seed = hashlib.sha256(self.seed).digest()
out += self.seed
msg = self.counter.to_bytes(4, "big")
out += hmac.new(self.key, msg, hashlib.sha256).digest()
self.counter += 1
return out[:n]
rsa_key = RSA.generate(2048, randfunc=DRNG(entropy))
+48 -23
View File
@@ -21,6 +21,7 @@ from utils.key_derivation import (
)
from .encryption import EncryptionManager
from utils.checksum import json_checksum, canonical_json_dumps
from .state_manager import StateManager
logger = logging.getLogger(__name__)
@@ -32,6 +33,7 @@ class PortableMode(Enum):
"""Encryption mode for portable exports."""
SEED_ONLY = EncryptionMode.SEED_ONLY.value
NONE = "none"
def _derive_export_key(seed: str) -> bytes:
@@ -47,8 +49,15 @@ def export_backup(
*,
publish: bool = False,
parent_seed: str | None = None,
encrypt: bool = True,
) -> Path:
"""Export the current vault state to a portable encrypted file."""
"""Export the current vault state to a portable file.
When ``encrypt`` is ``True`` (the default) the payload is encrypted with a
key derived from the parent seed. When ``encrypt`` is ``False`` the payload
is written in plaintext and the wrapper records an ``encryption_mode`` of
:data:`PortableMode.NONE`.
"""
if dest_path is None:
ts = int(time.time())
@@ -57,24 +66,32 @@ def export_backup(
dest_path = dest_dir / EXPORT_NAME_TEMPLATE.format(ts=ts)
index_data = vault.load_index()
seed = (
parent_seed
if parent_seed is not None
else vault.encryption_manager.decrypt_parent_seed()
)
key = _derive_export_key(seed)
enc_mgr = EncryptionManager(key, vault.fingerprint_dir)
canonical = canonical_json_dumps(index_data)
payload_bytes = enc_mgr.encrypt_data(canonical.encode("utf-8"))
if encrypt:
seed = (
parent_seed
if parent_seed is not None
else vault.encryption_manager.decrypt_parent_seed()
)
key = _derive_export_key(seed)
enc_mgr = EncryptionManager(key, vault.fingerprint_dir)
payload_bytes = enc_mgr.encrypt_data(canonical.encode("utf-8"))
mode = PortableMode.SEED_ONLY
cipher = "aes-gcm"
else:
payload_bytes = canonical.encode("utf-8")
mode = PortableMode.NONE
cipher = "none"
checksum = json_checksum(index_data)
wrapper = {
"format_version": FORMAT_VERSION,
"created_at": int(time.time()),
"fingerprint": vault.fingerprint_dir.name,
"encryption_mode": PortableMode.SEED_ONLY.value,
"cipher": "aes-gcm",
"encryption_mode": mode.value,
"cipher": cipher,
"checksum": checksum,
"payload": base64.b64encode(payload_bytes).decode("utf-8"),
}
@@ -90,10 +107,12 @@ def export_backup(
enc_file.write_bytes(encrypted)
os.chmod(enc_file, 0o600)
try:
idx = StateManager(vault.fingerprint_dir).state.get("nostr_account_idx", 0)
client = NostrClient(
vault.encryption_manager,
vault.fingerprint_dir.name,
config_manager=backup_manager.config_manager,
account_index=idx,
)
asyncio.run(client.publish_snapshot(encrypted))
except Exception:
@@ -112,24 +131,30 @@ def import_backup(
raw = Path(path).read_bytes()
if path.suffix.endswith(".enc"):
raw = vault.encryption_manager.decrypt_data(raw)
raw = vault.encryption_manager.decrypt_data(raw, context=str(path))
wrapper = json.loads(raw.decode("utf-8"))
if wrapper.get("format_version") != FORMAT_VERSION:
raise ValueError("Unsupported backup format")
if wrapper.get("encryption_mode") != PortableMode.SEED_ONLY.value:
raise ValueError("Unsupported encryption mode")
mode = wrapper.get("encryption_mode")
payload = base64.b64decode(wrapper["payload"])
seed = (
parent_seed
if parent_seed is not None
else vault.encryption_manager.decrypt_parent_seed()
)
key = _derive_export_key(seed)
enc_mgr = EncryptionManager(key, vault.fingerprint_dir)
index_bytes = enc_mgr.decrypt_data(payload)
if mode == PortableMode.SEED_ONLY.value:
seed = (
parent_seed
if parent_seed is not None
else vault.encryption_manager.decrypt_parent_seed()
)
key = _derive_export_key(seed)
enc_mgr = EncryptionManager(key, vault.fingerprint_dir)
enc_mgr._legacy_migrate_flag = False
index_bytes = enc_mgr.decrypt_data(payload, context="backup payload")
elif mode == PortableMode.NONE.value:
index_bytes = payload
else:
raise ValueError("Unsupported encryption mode")
index = json.loads(index_bytes.decode("utf-8"))
checksum = json_checksum(index)
+109
View File
@@ -0,0 +1,109 @@
from __future__ import annotations
import logging
from typing import Optional, TYPE_CHECKING
from termcolor import colored
import seedpass.core.manager as manager_module
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 = None
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),
key_index=pm.KEY_INDEX,
account_index=pm.nostr_account_idx,
)
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
+3
View File
@@ -26,6 +26,7 @@ class StateManager:
"manifest_id": None,
"delta_since": 0,
"relays": list(DEFAULT_RELAYS),
"nostr_account_idx": 0,
}
with shared_lock(self.state_path) as fh:
fh.seek(0)
@@ -37,6 +38,7 @@ class StateManager:
"manifest_id": None,
"delta_since": 0,
"relays": list(DEFAULT_RELAYS),
"nostr_account_idx": 0,
}
try:
obj = json.loads(data.decode())
@@ -47,6 +49,7 @@ class StateManager:
obj.setdefault("manifest_id", None)
obj.setdefault("delta_since", 0)
obj.setdefault("relays", list(DEFAULT_RELAYS))
obj.setdefault("nostr_account_idx", 0)
return obj
def _save(self, data: dict) -> None:
+14 -4
View File
@@ -2,8 +2,11 @@
from __future__ import annotations
import os
import sys
import time
import base64
from typing import Union
from urllib.parse import quote
from urllib.parse import urlparse, parse_qs, unquote
@@ -14,17 +17,24 @@ import pyotp
from utils import key_derivation
def random_totp_secret(length: int = 20) -> str:
"""Return a random Base32 encoded TOTP secret."""
return base64.b32encode(os.urandom(length)).decode("ascii").rstrip("=")
class TotpManager:
"""Helper methods for TOTP secrets and codes."""
@staticmethod
def derive_secret(seed: str, index: int) -> str:
"""Derive a TOTP secret from a BIP39 seed and index."""
def derive_secret(seed: Union[str, bytes], index: int) -> str:
"""Derive a TOTP secret from a seed or raw key and index."""
return key_derivation.derive_totp_secret(seed, index)
@classmethod
def current_code(cls, seed: str, index: int, timestamp: int | None = None) -> str:
"""Return the TOTP code for the given seed and index."""
def current_code(
cls, seed: Union[str, bytes], index: int, timestamp: int | None = None
) -> str:
"""Return the TOTP code for the given seed/key and index."""
secret = cls.derive_secret(seed, index)
totp = pyotp.TOTP(secret)
if timestamp is None:
+216 -11
View File
@@ -3,8 +3,19 @@
from pathlib import Path
from typing import Optional, Union
from os import PathLike
import shutil
from .encryption import EncryptionManager
from termcolor import colored
from cryptography.fernet import InvalidToken
from .encryption import (
EncryptionManager,
LegacyFormatRequiresMigrationError,
USE_ORJSON,
json_lib,
)
from utils.key_derivation import KdfConfig, CURRENT_KDF_VERSION
from utils.password_prompt import prompt_existing_password
class Vault:
@@ -22,26 +33,164 @@ class Vault:
self.fingerprint_dir = Path(fingerprint_dir)
self.index_file = self.fingerprint_dir / self.INDEX_FILENAME
self.config_file = self.fingerprint_dir / self.CONFIG_FILENAME
self.migrated_from_legacy = False
def set_encryption_manager(self, manager: EncryptionManager) -> None:
"""Replace the internal encryption manager."""
self.encryption_manager = manager
def _hkdf_kdf(self) -> KdfConfig:
return KdfConfig(
name="hkdf", version=CURRENT_KDF_VERSION, params={}, salt_b64=""
)
# ----- Password index helpers -----
def load_index(self) -> dict:
"""Return decrypted password index data as a dict, applying migrations."""
def load_index(self, *, return_migration_flags: bool = False):
"""Return decrypted password index data, applying migrations.
If a legacy ``seedpass_passwords_db.json.enc`` file is detected, the
user is prompted to migrate it. A backup copy of the legacy file (and
its checksum) is saved under ``legacy_backups`` within the fingerprint
directory before renaming to the new filename.
When ``return_migration_flags`` is ``True`` the tuple
``(data, migrated, last_migration_performed)`` is returned where
``migrated`` indicates whether any migration occurred and
``last_migration_performed`` reflects whether the underlying
:class:`EncryptionManager` reported a conversion.
"""
legacy_file = self.fingerprint_dir / "seedpass_passwords_db.json.enc"
self.migrated_from_legacy = False
legacy_detected = False
backup_dir = None
if legacy_file.exists() and not self.index_file.exists():
print(colored("Legacy index detected.", "yellow"))
resp = (
input("Would you like to migrate this to the new index format? [y/N]: ")
.strip()
.lower()
)
if resp != "y":
raise RuntimeError("Migration declined by user")
legacy_checksum = (
self.fingerprint_dir / "seedpass_passwords_db_checksum.txt"
)
backup_dir = self.fingerprint_dir / "legacy_backups"
backup_dir.mkdir(exist_ok=True)
shutil.copy2(legacy_file, backup_dir / legacy_file.name)
if legacy_checksum.exists():
shutil.copy2(legacy_checksum, backup_dir / legacy_checksum.name)
legacy_file.rename(self.index_file)
if legacy_checksum.exists():
legacy_checksum.rename(
self.fingerprint_dir / "seedpass_entries_db_checksum.txt"
)
data = self.encryption_manager.load_json_data(self.index_file)
# Remove any leftover legacy files to avoid triggering migration again
for stray in self.fingerprint_dir.glob("seedpass_passwords_db*.enc"):
try:
stray.unlink()
except FileNotFoundError:
pass
stray_checksum = self.fingerprint_dir / "seedpass_passwords_db_checksum.txt"
if stray_checksum.exists():
stray_checksum.unlink()
legacy_detected = True
print(
colored(
"Migration complete. Original index backed up to 'legacy_backups'",
"green",
)
)
try:
data, kdf = self.encryption_manager.load_json_data(
self.index_file, return_kdf=True
)
migration_performed = getattr(
self.encryption_manager, "last_migration_performed", False
)
if kdf.version < CURRENT_KDF_VERSION:
new_kdf = KdfConfig(
name=kdf.name,
version=CURRENT_KDF_VERSION,
params=kdf.params,
salt_b64=kdf.salt_b64,
)
self.encryption_manager.save_json_data(
data, self.index_file, kdf=new_kdf
)
self.encryption_manager.update_checksum(self.index_file)
migration_performed = True
except LegacyFormatRequiresMigrationError:
print(
colored(
"Failed to decrypt index with current key. This may be a legacy index.",
"red",
)
)
resp = input(
"\nChoose an option:\n"
"1. Open legacy index without migrating\n"
"2. Migrate to new format.\n"
"Selection [1/2]: "
).strip()
if resp == "1":
self.encryption_manager._legacy_migrate_flag = False
self.encryption_manager.last_migration_performed = False
elif resp == "2":
self.encryption_manager._legacy_migrate_flag = True
self.encryption_manager.last_migration_performed = True
else:
raise InvalidToken(
"User declined legacy decryption or provided invalid choice."
)
password = prompt_existing_password(
"Enter your master password for legacy decryption: "
)
with self.index_file.open("rb") as fh:
encrypted_data = fh.read()
decrypted = self.encryption_manager.decrypt_legacy(
encrypted_data, password, context=str(self.index_file)
)
if USE_ORJSON:
data = json_lib.loads(decrypted)
else:
data = json_lib.loads(decrypted.decode("utf-8"))
if self.encryption_manager._legacy_migrate_flag:
self.encryption_manager.save_json_data(
data, self.index_file, kdf=self._hkdf_kdf()
)
self.encryption_manager.update_checksum(self.index_file)
migration_performed = getattr(
self.encryption_manager, "last_migration_performed", False
)
except Exception as exc: # noqa: BLE001 - surface clear error and restore
if legacy_detected and backup_dir is not None:
backup_file = backup_dir / legacy_file.name
legacy_checksum_path = (
self.fingerprint_dir / "seedpass_passwords_db_checksum.txt"
)
backup_checksum = backup_dir / legacy_checksum_path.name
try:
if self.index_file.exists():
self.index_file.unlink()
shutil.copy2(backup_file, legacy_file)
checksum_new = (
self.fingerprint_dir / "seedpass_entries_db_checksum.txt"
)
if checksum_new.exists():
checksum_new.unlink()
if backup_checksum.exists():
shutil.copy2(backup_checksum, legacy_checksum_path)
finally:
self.migrated_from_legacy = False
raise RuntimeError(f"Migration failed: {exc}") from exc
from .migrations import apply_migrations, LATEST_VERSION
version = data.get("schema_version", 0)
@@ -49,24 +198,78 @@ class Vault:
raise ValueError(
f"File schema version {version} is newer than supported {LATEST_VERSION}"
)
data = apply_migrations(data)
schema_migrated = version < LATEST_VERSION
try:
data = apply_migrations(data)
if schema_migrated:
self.encryption_manager.save_json_data(
data, self.index_file, kdf=self._hkdf_kdf()
)
self.encryption_manager.update_checksum(self.index_file)
except Exception as exc: # noqa: BLE001 - surface clear error and restore
if legacy_detected and backup_dir is not None:
backup_file = backup_dir / legacy_file.name
legacy_checksum_path = (
self.fingerprint_dir / "seedpass_passwords_db_checksum.txt"
)
backup_checksum = backup_dir / legacy_checksum_path.name
try:
if self.index_file.exists():
self.index_file.unlink()
shutil.copy2(backup_file, legacy_file)
checksum_new = (
self.fingerprint_dir / "seedpass_entries_db_checksum.txt"
)
if checksum_new.exists():
checksum_new.unlink()
if backup_checksum.exists():
shutil.copy2(backup_checksum, legacy_checksum_path)
finally:
self.migrated_from_legacy = False
raise RuntimeError(f"Migration failed: {exc}") from exc
self.migrated_from_legacy = (
legacy_detected or migration_performed or schema_migrated
)
if return_migration_flags:
return data, self.migrated_from_legacy, migration_performed
return data
def save_index(self, data: dict) -> None:
"""Encrypt and write password index."""
self.encryption_manager.save_json_data(data, self.index_file)
self.encryption_manager.save_json_data(
data, self.index_file, kdf=self._hkdf_kdf()
)
def get_encrypted_index(self) -> Optional[bytes]:
"""Return the encrypted index bytes if present."""
return self.encryption_manager.get_encrypted_index()
def decrypt_and_save_index_from_nostr(
self, encrypted_data: bytes, *, strict: bool = True, merge: bool = False
) -> bool:
"""Decrypt Nostr payload and update the local index."""
return self.encryption_manager.decrypt_and_save_index_from_nostr(
self,
encrypted_data: bytes,
*,
strict: bool = True,
merge: bool = False,
return_migration_flag: bool = False,
):
"""Decrypt Nostr payload and update the local index.
Returns ``True``/``False`` for success by default. When
``return_migration_flag`` is ``True`` a tuple ``(success, migrated)`` is
returned, where ``migrated`` indicates whether any legacy migration
occurred.
"""
result = self.encryption_manager.decrypt_and_save_index_from_nostr(
encrypted_data, strict=strict, merge=merge
)
self.migrated_from_legacy = result and getattr(
self.encryption_manager, "last_migration_performed", False
)
if return_migration_flag:
return result, self.migrated_from_legacy
return result
# ----- Config helpers -----
def load_config(self) -> dict:
@@ -75,4 +278,6 @@ class Vault:
def save_config(self, config: dict) -> None:
"""Encrypt and persist configuration."""
self.encryption_manager.save_json_data(config, self.config_file)
self.encryption_manager.save_json_data(
config, self.config_file, kdf=self._hkdf_kdf()
)
+13
View File
@@ -0,0 +1,13 @@
"""Compatibility layer for historic exception types."""
from .core.errors import SeedPassError
class VaultLockedError(SeedPassError):
"""Raised when an operation requires an unlocked vault."""
def __init__(self, message: str = "Vault is locked") -> None:
super().__init__(message)
__all__ = ["VaultLockedError", "SeedPassError"]
+1 -1
View File
@@ -1,4 +1,4 @@
from .app import main
from . import main
if __name__ == "__main__":
main()
+1 -1
View File
@@ -394,7 +394,7 @@ class TotpViewerWindow(toga.Window):
def refresh_codes(self) -> None:
self.table.data = []
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)
+145
View File
@@ -1,5 +1,40 @@
import importlib.util
import logging
import sys
from pathlib import Path
import pytest
from types import SimpleNamespace
from httpx import ASGITransport, AsyncClient
import bcrypt
sys.path.append(str(Path(__file__).resolve().parents[1]))
from seedpass import api
from seedpass.core.entry_types import EntryType
from helpers import (
create_vault,
TEST_PASSWORD,
TEST_SEED,
DummyBuilder,
DummyFilter,
DummyTag,
DummyTimestamp,
DummyEventId,
DummyRelayClient,
)
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 EncryptionMode, PasswordManager
from seedpass.core.encryption import EncryptionManager as EncMgr
@pytest.fixture(
params=["asyncio"] + (["trio"] if importlib.util.find_spec("trio") else [])
)
def anyio_backend(request):
return request.param
@pytest.fixture(autouse=True)
@@ -49,3 +84,113 @@ def pytest_collection_modifyitems(
for item in items:
if "desktop" in item.keywords:
item.add_marker(skip_desktop)
@pytest.fixture
def vault(tmp_path):
vault, _ = create_vault(tmp_path, TEST_SEED, TEST_PASSWORD)
return vault
@pytest.fixture
def password_manager(vault, tmp_path):
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 = vault.encryption_manager
pm.vault = vault
pm.entry_manager = entry_mgr
pm.backup_manager = backup_mgr
pm.parent_seed = TEST_SEED
pm.nostr_client = None
pm.fingerprint_dir = tmp_path
pm.is_dirty = False
pm.secret_mode_enabled = False
return pm
@pytest.fixture
async def client(monkeypatch):
dummy = SimpleNamespace(
entry_manager=SimpleNamespace(
search_entries=lambda q: [
(1, "Site", "user", "url", False, EntryType.PASSWORD)
],
retrieve_entry=lambda i: {"label": "Site"},
add_entry=lambda *a, **k: 1,
modify_entry=lambda *a, **k: None,
archive_entry=lambda i: None,
restore_entry=lambda i: None,
),
config_manager=SimpleNamespace(
load_config=lambda require_pin=False: {"k": "v"},
set_pin=lambda v: None,
set_password_hash=lambda v: None,
set_relays=lambda v, require_pin=False: None,
set_inactivity_timeout=lambda v: None,
set_additional_backup_path=lambda v: None,
set_secret_mode_enabled=lambda v: None,
set_clipboard_clear_delay=lambda v: None,
set_quick_unlock=lambda v: None,
),
fingerprint_manager=SimpleNamespace(list_fingerprints=lambda: ["fp"]),
nostr_client=SimpleNamespace(
key_manager=SimpleNamespace(get_npub=lambda: "np")
),
verify_password=lambda pw: True,
)
monkeypatch.setattr(api, "PasswordManager", lambda: dummy)
monkeypatch.setenv("SEEDPASS_CORS_ORIGINS", "http://example.com")
token = api.start_server()
transport = ASGITransport(app=api.app)
async with AsyncClient(transport=transport, base_url="http://test") as ac:
yield ac, token
@pytest.fixture
def make_dummy_nostr_client(monkeypatch):
"""Factory to return a NostrClient wired to a DummyRelayClient."""
from cryptography.fernet import Fernet
from nostr.client import NostrClient
def _make(base_path):
relay = DummyRelayClient()
monkeypatch.setattr("nostr.client.Client", lambda signer: relay)
monkeypatch.setattr("nostr.client.EventBuilder", DummyBuilder)
monkeypatch.setattr("nostr.client.Filter", DummyFilter)
monkeypatch.setattr("nostr.client.Tag", DummyTag)
monkeypatch.setattr("nostr.client.Timestamp", DummyTimestamp)
monkeypatch.setattr("nostr.client.EventId", DummyEventId)
from nostr.backup_models import KIND_DELTA as KD
monkeypatch.setattr("nostr.client.KIND_DELTA", KD, raising=False)
monkeypatch.setattr(NostrClient, "initialize_client_pool", lambda self: None)
enc_mgr = EncMgr(Fernet.generate_key(), base_path)
class DummyKeys:
def private_key_hex(self):
return "1" * 64
def public_key_hex(self):
return "2" * 64
class DummyKeyManager:
def __init__(self, *a, **k):
self.keys = DummyKeys()
with pytest.MonkeyPatch().context() as mp:
mp.setattr("nostr.client.KeyManager", DummyKeyManager)
mp.setattr(enc_mgr, "decrypt_parent_seed", lambda: TEST_SEED)
client = NostrClient(enc_mgr, "fp")
return client, relay
return _make
@pytest.fixture
def dummy_nostr_client(make_dummy_nostr_client, tmp_path):
return make_dummy_nostr_client(tmp_path)
+13 -55
View File
@@ -1,18 +1,24 @@
import sys
import time
import json
import uuid
from pathlib import Path
sys.path.append(str(Path(__file__).resolve().parents[1]))
from seedpass.core.vault import Vault
from seedpass.core.encryption import EncryptionManager
from utils.key_derivation import (
derive_index_key,
derive_key_from_password,
)
from utils.fingerprint import generate_fingerprint
from nostr.backup_models import (
KIND_MANIFEST,
KIND_SNAPSHOT_CHUNK,
KIND_DELTA,
)
TEST_SEED = "abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon about"
TEST_SEED = (
"abandon abandon abandon abandon abandon abandon abandon abandon abandon "
"abandon abandon about"
)
TEST_PASSWORD = "pw"
@@ -22,7 +28,8 @@ def create_vault(
password: str = TEST_PASSWORD,
) -> tuple[Vault, EncryptionManager]:
"""Create a Vault initialized for tests."""
seed_key = derive_key_from_password(password)
fp = generate_fingerprint(seed)
seed_key = derive_key_from_password(password, fp)
seed_mgr = EncryptionManager(seed_key, dir_path)
seed_mgr.encrypt_parent_seed(seed)
@@ -32,17 +39,6 @@ def create_vault(
return vault, enc_mgr
import uuid
import asyncio
import pytest
from nostr.backup_models import (
KIND_MANIFEST,
KIND_SNAPSHOT_CHUNK,
KIND_DELTA,
)
class DummyEvent:
def __init__(self, kind: int, content: str, tags=None, event_id: str | None = None):
self.kind = kind
@@ -235,41 +231,3 @@ class DummyRelayClient:
return self._evs
return Result(events)
@pytest.fixture
def dummy_nostr_client(tmp_path, monkeypatch):
"""Return a NostrClient wired to a DummyRelayClient."""
from cryptography.fernet import Fernet
from nostr.client import NostrClient
relay = DummyRelayClient()
monkeypatch.setattr("nostr.client.Client", lambda signer: relay)
monkeypatch.setattr("nostr.client.EventBuilder", DummyBuilder)
monkeypatch.setattr("nostr.client.Filter", DummyFilter)
monkeypatch.setattr("nostr.client.Tag", DummyTag)
monkeypatch.setattr("nostr.client.Timestamp", DummyTimestamp)
monkeypatch.setattr("nostr.client.EventId", DummyEventId)
from nostr.backup_models import KIND_DELTA as KD
monkeypatch.setattr("nostr.client.KIND_DELTA", KD, raising=False)
monkeypatch.setattr(NostrClient, "initialize_client_pool", lambda self: None)
enc_mgr = EncryptionManager(Fernet.generate_key(), tmp_path)
class DummyKeys:
def private_key_hex(self):
return "1" * 64
def public_key_hex(self):
return "2" * 64
class DummyKeyManager:
def __init__(self, *a, **k):
self.keys = DummyKeys()
with pytest.MonkeyPatch().context() as mp:
mp.setattr("nostr.client.KeyManager", DummyKeyManager)
mp.setattr(enc_mgr, "decrypt_parent_seed", lambda: TEST_SEED)
client = NostrClient(enc_mgr, "fp")
return client, relay
@@ -0,0 +1,35 @@
import builtins
from types import SimpleNamespace
import seedpass.core.manager as manager_module
from helpers import TEST_SEED
def test_add_new_fingerprint_word_entry_exits(monkeypatch):
pm = manager_module.PasswordManager.__new__(manager_module.PasswordManager)
pm.fingerprint_manager = SimpleNamespace(current_fingerprint=None)
pm.initialize_managers = lambda: None
calls = {"count": 0}
original_setup = manager_module.PasswordManager.setup_existing_seed
def setup_wrapper(self, *a, **k):
calls["count"] += 1
return original_setup(self, *a, **k)
monkeypatch.setattr(
manager_module.PasswordManager, "setup_existing_seed", setup_wrapper
)
monkeypatch.setattr(manager_module, "prompt_seed_words", lambda *a, **k: TEST_SEED)
monkeypatch.setattr(
manager_module.PasswordManager,
"_finalize_existing_seed",
lambda self, seed, password=None: "fp",
)
monkeypatch.setattr(builtins, "input", lambda *_a, **_k: "2")
result = pm.add_new_fingerprint()
assert result == "fp"
assert calls["count"] == 1
assert pm.fingerprint_manager.current_fingerprint == "fp"
+183
View File
@@ -0,0 +1,183 @@
from __future__ import annotations
import importlib.util
from pathlib import Path
import sys
import pytest
def _load_harness_module():
repo_root = Path(__file__).resolve().parents[2]
script_path = repo_root / "scripts" / "ai_tui_agent_test.py"
spec = importlib.util.spec_from_file_location("ai_tui_agent_test", script_path)
assert spec is not None and spec.loader is not None
module = importlib.util.module_from_spec(spec)
sys.modules[spec.name] = module
spec.loader.exec_module(module)
return module
harness = _load_harness_module()
def test_run_step_records_passed_result():
results = []
called = {"value": False}
def _ok():
called["value"] = True
harness._run_step("step_a", "happy path", _ok, results, verbose=False)
assert called["value"] is True
assert len(results) == 1
assert results[0].step_id == "step_a"
assert results[0].status == "passed"
assert results[0].detail is None
def test_run_step_records_failed_result_and_reraises():
results = []
def _boom():
raise RuntimeError("boom")
with pytest.raises(RuntimeError, match="boom"):
harness._run_step("step_b", "failing path", _boom, results, verbose=False)
assert len(results) == 1
assert results[0].step_id == "step_b"
assert results[0].status == "failed"
assert results[0].detail == "boom"
def test_add_entry_menu_open_waits_when_choice_omitted():
class Runner:
def __init__(self):
self.sent = []
self.waited = []
def sendline(self, value=""):
self.sent.append(value)
def wait_for(self, pattern, timeout):
self.waited.append((pattern, timeout))
runner = Runner()
harness._add_entry_menu_open(runner, timeout=5.0, choice=None)
assert runner.sent == ["1"]
assert len(runner.waited) == 1
assert "Select entry type or press Enter to go back:" in runner.waited[0][0]
def test_add_entry_menu_open_sends_choice_when_provided():
class Runner:
def __init__(self):
self.sent = []
self.expect_calls = []
def sendline(self, value=""):
self.sent.append(value)
def expect_and_send(self, pattern, value, timeout):
self.expect_calls.append((pattern, value, timeout))
runner = Runner()
harness._add_entry_menu_open(runner, timeout=5.0, choice="7")
assert runner.sent == ["1"]
assert runner.expect_calls == [
("Select entry type or press Enter to go back:", "7", 5.0)
]
def test_complete_onboarding_reaches_main_menu_and_sends_password():
class Runner:
def __init__(self):
self.calls = [
(r"Enter your choice \(1-8\) or press Enter to exit:", False),
(r"Enter a new password:", True),
(r"Enter your choice \(1-8\) or press Enter to exit:", False),
(r"Enter a new password:", False),
(r"Confirm your password:", True),
(r"Enter your choice \(1-8\) or press Enter to exit:", True),
]
self.sent = []
def try_wait_for(self, pattern, timeout):
expected_pattern, ret = self.calls.pop(0)
assert pattern == expected_pattern
return ret
def sendline(self, value=""):
self.sent.append(value)
def pump(self, duration):
return None
runner = Runner()
harness._complete_onboarding_to_main_menu(runner, timeout=1.0, password="pw123")
assert runner.sent == ["pw123", "pw123"]
assert runner.calls == []
def test_complete_onboarding_times_out_without_expected_prompts():
class Runner:
def try_wait_for(self, pattern, timeout):
return False
def sendline(self, value=""):
return None
def pump(self, duration):
return None
runner = Runner()
with pytest.raises(harness.TUIHarnessError, match="Onboarding did not reach main"):
harness._complete_onboarding_to_main_menu(runner, timeout=0.0, password="pw")
def test_drain_to_main_menu_uses_helper_prompt_then_returns():
class Runner:
def __init__(self):
self.main_attempts = 0
self.helper_calls = []
def wait_for(self, pattern, timeout):
self.main_attempts += 1
if self.main_attempts == 1:
raise harness.TUIHarnessError("not yet")
return None
def expect_and_send(self, pattern, value, timeout):
self.helper_calls.append((pattern, value, timeout))
return None
def pump(self, duration):
return None
runner = Runner()
harness._drain_to_main_menu(runner, timeout=1.0)
assert runner.main_attempts == 2
assert len(runner.helper_calls) == 1
assert runner.helper_calls[0][1] == ""
def test_drain_to_main_menu_times_out_when_no_prompt_progress(monkeypatch):
class Runner:
def wait_for(self, pattern, timeout):
raise harness.TUIHarnessError("missing")
def expect_and_send(self, pattern, value, timeout):
raise harness.TUIHarnessError("missing helper")
def pump(self, duration):
return None
timeline = iter([0.0, 0.05, 0.15])
monkeypatch.setattr(harness.time, "monotonic", lambda: next(timeline))
with pytest.raises(
harness.TUIHarnessError, match="Unable to return to main menu within timeout"
):
harness._drain_to_main_menu(Runner(), timeout=0.1)
+69 -81
View File
@@ -1,63 +1,49 @@
from types import SimpleNamespace
from pathlib import Path
import sys
import pytest
from fastapi.testclient import TestClient
import bcrypt
sys.path.append(str(Path(__file__).resolve().parents[1]))
from seedpass import api
from seedpass.core.entry_types import EntryType
@pytest.fixture
def client(monkeypatch):
dummy = SimpleNamespace(
entry_manager=SimpleNamespace(
search_entries=lambda q: [
(1, "Site", "user", "url", False, EntryType.PASSWORD)
],
retrieve_entry=lambda i: {"label": "Site"},
add_entry=lambda *a, **k: 1,
modify_entry=lambda *a, **k: None,
archive_entry=lambda i: None,
restore_entry=lambda i: None,
),
config_manager=SimpleNamespace(
load_config=lambda require_pin=False: {"k": "v"},
set_pin=lambda v: None,
set_password_hash=lambda v: None,
set_relays=lambda v, require_pin=False: None,
set_inactivity_timeout=lambda v: None,
set_additional_backup_path=lambda v: None,
set_secret_mode_enabled=lambda v: None,
set_clipboard_clear_delay=lambda v: None,
set_quick_unlock=lambda v: None,
),
fingerprint_manager=SimpleNamespace(list_fingerprints=lambda: ["fp"]),
nostr_client=SimpleNamespace(
key_manager=SimpleNamespace(get_npub=lambda: "np")
),
)
monkeypatch.setattr(api, "PasswordManager", lambda: dummy)
monkeypatch.setenv("SEEDPASS_CORS_ORIGINS", "http://example.com")
token = api.start_server()
client = TestClient(api.app)
return client, token
def test_get_pm_requires_initialized_state():
class DummyState:
pass
class DummyApp:
state = DummyState()
class DummyRequest:
app = DummyApp()
with pytest.raises(api.HTTPException) as exc:
api._get_pm(DummyRequest())
assert exc.value.status_code == 503
def test_cors_and_auth(client):
@pytest.mark.anyio
async def test_token_hashed(client):
_, token = client
assert api.app.state.token_hash != token
assert bcrypt.checkpw(token.encode(), api.app.state.token_hash)
@pytest.mark.anyio
async def test_cors_and_auth(client):
cl, token = client
headers = {"Authorization": f"Bearer {token}", "Origin": "http://example.com"}
res = cl.get("/api/v1/entry", params={"query": "s"}, headers=headers)
res = await cl.get("/api/v1/entry", params={"query": "s"}, headers=headers)
assert res.status_code == 200
assert res.headers.get("access-control-allow-origin") == "http://example.com"
def test_invalid_token(client):
@pytest.mark.anyio
async def test_invalid_token(client):
cl, _token = client
res = cl.get(
res = await cl.get(
"/api/v1/entry",
params={"query": "s"},
headers={"Authorization": "Bearer bad"},
@@ -65,69 +51,65 @@ def test_invalid_token(client):
assert res.status_code == 401
def test_missing_token(client):
cl, _token = client
# No Authorization header
res = cl.get(
"/api/v1/entry",
params={"query": "s"},
)
assert res.status_code == 401
def test_get_entry_by_id(client):
@pytest.mark.anyio
async def test_get_entry_by_id(client):
cl, token = client
headers = {
"Authorization": f"Bearer {token}",
"Origin": "http://example.com",
"X-SeedPass-Password": "pw",
}
res = cl.get("/api/v1/entry/1", headers=headers)
res = await cl.get("/api/v1/entry/1", headers=headers)
assert res.status_code == 200
assert res.json() == {"label": "Site"}
assert res.headers.get("access-control-allow-origin") == "http://example.com"
def test_get_config_value(client):
@pytest.mark.anyio
async def test_get_config_value(client):
cl, token = client
headers = {
"Authorization": f"Bearer {token}",
"Origin": "http://example.com",
}
res = cl.get("/api/v1/config/k", headers=headers)
res = await cl.get("/api/v1/config/k", headers=headers)
assert res.status_code == 200
assert res.json() == {"key": "k", "value": "v"}
assert res.headers.get("access-control-allow-origin") == "http://example.com"
def test_list_fingerprint(client):
@pytest.mark.anyio
async def test_list_fingerprint(client):
cl, token = client
headers = {
"Authorization": f"Bearer {token}",
"Origin": "http://example.com",
}
res = cl.get("/api/v1/fingerprint", headers=headers)
res = await cl.get("/api/v1/fingerprint", headers=headers)
assert res.status_code == 200
assert res.json() == ["fp"]
assert res.headers.get("access-control-allow-origin") == "http://example.com"
def test_get_nostr_pubkey(client):
@pytest.mark.anyio
async def test_get_nostr_pubkey(client):
cl, token = client
headers = {
"Authorization": f"Bearer {token}",
"Origin": "http://example.com",
}
res = cl.get("/api/v1/nostr/pubkey", headers=headers)
res = await cl.get("/api/v1/nostr/pubkey", headers=headers)
assert res.status_code == 200
assert res.json() == {"npub": "np"}
assert res.headers.get("access-control-allow-origin") == "http://example.com"
def test_create_modify_archive_entry(client):
@pytest.mark.anyio
async def test_create_modify_archive_entry(client):
cl, token = client
headers = {"Authorization": f"Bearer {token}", "Origin": "http://example.com"}
res = cl.post(
res = await cl.post(
"/api/v1/entry",
json={"label": "test", "length": 12},
headers=headers,
@@ -135,7 +117,7 @@ def test_create_modify_archive_entry(client):
assert res.status_code == 200
assert res.json() == {"id": 1}
res = cl.put(
res = await cl.put(
"/api/v1/entry/1",
json={"username": "bob"},
headers=headers,
@@ -143,25 +125,26 @@ def test_create_modify_archive_entry(client):
assert res.status_code == 200
assert res.json() == {"status": "ok"}
res = cl.post("/api/v1/entry/1/archive", headers=headers)
res = await cl.post("/api/v1/entry/1/archive", headers=headers)
assert res.status_code == 200
assert res.json() == {"status": "archived"}
res = cl.post("/api/v1/entry/1/unarchive", headers=headers)
res = await cl.post("/api/v1/entry/1/unarchive", headers=headers)
assert res.status_code == 200
assert res.json() == {"status": "active"}
def test_update_config(client):
@pytest.mark.anyio
async def test_update_config(client):
cl, token = client
called = {}
def set_timeout(val):
called["val"] = val
api._pm.config_manager.set_inactivity_timeout = set_timeout
api.app.state.pm.config_manager.set_inactivity_timeout = set_timeout
headers = {"Authorization": f"Bearer {token}", "Origin": "http://example.com"}
res = cl.put(
res = await cl.put(
"/api/v1/config/inactivity_timeout",
json={"value": 42},
headers=headers,
@@ -172,13 +155,15 @@ def test_update_config(client):
assert res.headers.get("access-control-allow-origin") == "http://example.com"
def test_update_config_quick_unlock(client):
@pytest.mark.anyio
async def test_update_config_quick_unlock(client):
cl, token = client
called = {}
api._pm.config_manager.set_quick_unlock = lambda v: called.setdefault("val", v)
api.app.state.pm.config_manager.set_quick_unlock = lambda v: called.setdefault(
"val", v
)
headers = {"Authorization": f"Bearer {token}", "Origin": "http://example.com"}
res = cl.put(
res = await cl.put(
"/api/v1/config/quick_unlock",
json={"value": True},
headers=headers,
@@ -188,13 +173,13 @@ def test_update_config_quick_unlock(client):
assert called.get("val") is True
def test_change_password_route(client):
@pytest.mark.anyio
async def test_change_password_route(client):
cl, token = client
called = {}
api._pm.change_password = lambda o, n: called.setdefault("called", (o, n))
api.app.state.pm.change_password = lambda o, n: called.setdefault("called", (o, n))
headers = {"Authorization": f"Bearer {token}", "Origin": "http://example.com"}
res = cl.post(
res = await cl.post(
"/api/v1/change-password",
headers=headers,
json={"old": "old", "new": "new"},
@@ -205,10 +190,11 @@ def test_change_password_route(client):
assert res.headers.get("access-control-allow-origin") == "http://example.com"
def test_update_config_unknown_key(client):
@pytest.mark.anyio
async def test_update_config_unknown_key(client):
cl, token = client
headers = {"Authorization": f"Bearer {token}", "Origin": "http://example.com"}
res = cl.put(
res = await cl.put(
"/api/v1/config/bogus",
json={"value": 1},
headers=headers,
@@ -216,7 +202,8 @@ def test_update_config_unknown_key(client):
assert res.status_code == 400
def test_shutdown(client, monkeypatch):
@pytest.mark.anyio
async def test_shutdown(client, monkeypatch):
cl, token = client
calls = {}
@@ -232,7 +219,7 @@ def test_shutdown(client, monkeypatch):
"Authorization": f"Bearer {token}",
"Origin": "http://example.com",
}
res = cl.post("/api/v1/shutdown", headers=headers)
res = await cl.post("/api/v1/shutdown", headers=headers)
assert res.status_code == 200
assert res.json() == {"status": "shutting down"}
assert calls["func"] is sys.exit
@@ -240,6 +227,7 @@ def test_shutdown(client, monkeypatch):
assert res.headers.get("access-control-allow-origin") == "http://example.com"
@pytest.mark.anyio
@pytest.mark.parametrize(
"method,path",
[
@@ -257,11 +245,11 @@ def test_shutdown(client, monkeypatch):
("post", "/api/v1/vault/lock"),
],
)
def test_invalid_token_other_endpoints(client, method, path):
async def test_invalid_token_other_endpoints(client, method, path):
cl, _token = client
req = getattr(cl, method)
kwargs = {"headers": {"Authorization": "Bearer bad"}}
if method in {"post", "put"}:
kwargs["json"] = {}
res = req(path, **kwargs)
res = await req(path, **kwargs)
assert res.status_code == 401
+245 -125
View File
@@ -1,16 +1,18 @@
from types import SimpleNamespace
from pathlib import Path
import os
import base64
import pytest
from seedpass import api
from test_api import client
from helpers import dummy_nostr_client
import string
from seedpass.core.password_generation import PasswordGenerator, PasswordPolicy
from seedpass.core.encryption import EncryptionManager
from nostr.client import NostrClient, DEFAULT_RELAYS
def test_create_and_modify_totp_entry(client):
@pytest.mark.anyio
async def test_create_and_modify_totp_entry(client):
cl, token = client
calls = {}
@@ -21,13 +23,13 @@ def test_create_and_modify_totp_entry(client):
def modify(idx, **kwargs):
calls["modify"] = (idx, kwargs)
api._pm.entry_manager.add_totp = add_totp
api._pm.entry_manager.modify_entry = modify
api._pm.entry_manager.get_next_index = lambda: 5
api._pm.parent_seed = "seed"
api.app.state.pm.entry_manager.add_totp = add_totp
api.app.state.pm.entry_manager.modify_entry = modify
api.app.state.pm.entry_manager.get_next_index = lambda: 5
api.app.state.pm.parent_seed = "seed"
headers = {"Authorization": f"Bearer {token}"}
res = cl.post(
res = await cl.post(
"/api/v1/entry",
json={
"type": "totp",
@@ -49,9 +51,10 @@ def test_create_and_modify_totp_entry(client):
"digits": 8,
"notes": "n",
"archived": False,
"deterministic": False,
}
res = cl.put(
res = await cl.put(
"/api/v1/entry/5",
json={"period": 90, "digits": 6},
headers=headers,
@@ -62,7 +65,8 @@ def test_create_and_modify_totp_entry(client):
assert calls["modify"][1]["digits"] == 6
def test_create_and_modify_ssh_entry(client):
@pytest.mark.anyio
async def test_create_and_modify_ssh_entry(client):
cl, token = client
calls = {}
@@ -73,12 +77,12 @@ def test_create_and_modify_ssh_entry(client):
def modify(idx, **kwargs):
calls["modify"] = (idx, kwargs)
api._pm.entry_manager.add_ssh_key = add_ssh
api._pm.entry_manager.modify_entry = modify
api._pm.parent_seed = "seed"
api.app.state.pm.entry_manager.add_ssh_key = add_ssh
api.app.state.pm.entry_manager.modify_entry = modify
api.app.state.pm.parent_seed = "seed"
headers = {"Authorization": f"Bearer {token}"}
res = cl.post(
res = await cl.post(
"/api/v1/entry",
json={"type": "ssh", "label": "S", "index": 2, "notes": "n"},
headers=headers,
@@ -87,7 +91,7 @@ def test_create_and_modify_ssh_entry(client):
assert res.json() == {"id": 2}
assert calls["create"] == {"index": 2, "notes": "n", "archived": False}
res = cl.put(
res = await cl.put(
"/api/v1/entry/2",
json={"notes": "x"},
headers=headers,
@@ -97,29 +101,31 @@ def test_create_and_modify_ssh_entry(client):
assert calls["modify"][1]["notes"] == "x"
def test_update_entry_error(client):
@pytest.mark.anyio
async def test_update_entry_error(client):
cl, token = client
def modify(*a, **k):
raise ValueError("nope")
api._pm.entry_manager.modify_entry = modify
api.app.state.pm.entry_manager.modify_entry = modify
headers = {"Authorization": f"Bearer {token}"}
res = cl.put("/api/v1/entry/1", json={"username": "x"}, headers=headers)
res = await cl.put("/api/v1/entry/1", json={"username": "x"}, headers=headers)
assert res.status_code == 400
assert res.json() == {"detail": "nope"}
def test_update_config_secret_mode(client):
@pytest.mark.anyio
async def test_update_config_secret_mode(client):
cl, token = client
called = {}
def set_secret(val):
called["val"] = val
api._pm.config_manager.set_secret_mode_enabled = set_secret
api.app.state.pm.config_manager.set_secret_mode_enabled = set_secret
headers = {"Authorization": f"Bearer {token}"}
res = cl.put(
res = await cl.put(
"/api/v1/config/secret_mode_enabled",
json={"value": True},
headers=headers,
@@ -129,24 +135,28 @@ def test_update_config_secret_mode(client):
assert called["val"] is True
def test_totp_export_endpoint(client):
@pytest.mark.anyio
async def test_totp_export_endpoint(client):
cl, token = client
api._pm.entry_manager.export_totp_entries = lambda seed: {"entries": ["x"]}
api._pm.parent_seed = "seed"
headers = {"Authorization": f"Bearer {token}"}
res = cl.get("/api/v1/totp/export", headers=headers)
api.app.state.pm.entry_manager.export_totp_entries = lambda seed: {"entries": ["x"]}
api.app.state.pm.parent_seed = "seed"
headers = {"Authorization": f"Bearer {token}", "X-SeedPass-Password": "pw"}
res = await cl.get("/api/v1/totp/export", headers=headers)
assert res.status_code == 200
assert res.json() == {"entries": ["x"]}
def test_totp_codes_endpoint(client):
@pytest.mark.anyio
async def test_totp_codes_endpoint(client):
cl, token = client
api._pm.entry_manager.list_entries = lambda **kw: [(0, "Email", None, None, False)]
api._pm.entry_manager.get_totp_code = lambda i, s: "123456"
api._pm.entry_manager.get_totp_time_remaining = lambda i: 30
api._pm.parent_seed = "seed"
headers = {"Authorization": f"Bearer {token}"}
res = cl.get("/api/v1/totp", headers=headers)
api.app.state.pm.entry_manager.list_entries = lambda **kw: [
(0, "Email", None, None, False)
]
api.app.state.pm.entry_manager.get_totp_code = lambda i, s: "123456"
api.app.state.pm.entry_manager.get_totp_time_remaining = lambda i: 30
api.app.state.pm.parent_seed = "seed"
headers = {"Authorization": f"Bearer {token}", "X-SeedPass-Password": "pw"}
res = await cl.get("/api/v1/totp", headers=headers)
assert res.status_code == 200
assert res.json() == {
"codes": [
@@ -155,49 +165,39 @@ def test_totp_codes_endpoint(client):
}
def test_parent_seed_endpoint(client, tmp_path):
@pytest.mark.anyio
async def test_parent_seed_endpoint_removed(client):
cl, token = client
api._pm.parent_seed = "seed"
called = {}
api._pm.encryption_manager = SimpleNamespace(
encrypt_and_save_file=lambda data, path: called.setdefault("path", path)
res = await cl.get(
"/api/v1/parent-seed", headers={"Authorization": f"Bearer {token}"}
)
headers = {"Authorization": f"Bearer {token}"}
res = cl.get("/api/v1/parent-seed", headers=headers)
assert res.status_code == 200
assert res.json() == {"seed": "seed"}
out = tmp_path / "bk.enc"
res = cl.get("/api/v1/parent-seed", params={"file": str(out)}, headers=headers)
assert res.status_code == 200
assert res.json() == {"status": "saved", "path": str(out)}
assert called["path"] == out
assert res.status_code == 404
def test_fingerprint_endpoints(client):
@pytest.mark.anyio
async def test_fingerprint_endpoints(client):
cl, token = client
calls = {}
api._pm.add_new_fingerprint = lambda: calls.setdefault("add", True)
api._pm.fingerprint_manager.remove_fingerprint = lambda fp: calls.setdefault(
"remove", fp
api.app.state.pm.add_new_fingerprint = lambda: calls.setdefault("add", True)
api.app.state.pm.fingerprint_manager.remove_fingerprint = (
lambda fp: calls.setdefault("remove", fp)
)
api._pm.select_fingerprint = lambda fp: calls.setdefault("select", fp)
api.app.state.pm.select_fingerprint = lambda fp: calls.setdefault("select", fp)
headers = {"Authorization": f"Bearer {token}"}
res = cl.post("/api/v1/fingerprint", headers=headers)
res = await cl.post("/api/v1/fingerprint", headers=headers)
assert res.status_code == 200
assert res.json() == {"status": "ok"}
assert calls.get("add") is True
res = cl.delete("/api/v1/fingerprint/abc", headers=headers)
res = await cl.delete("/api/v1/fingerprint/abc", headers=headers)
assert res.status_code == 200
assert res.json() == {"status": "deleted"}
assert calls.get("remove") == "abc"
res = cl.post(
res = await cl.post(
"/api/v1/fingerprint/select",
json={"fingerprint": "xyz"},
headers=headers,
@@ -207,44 +207,49 @@ def test_fingerprint_endpoints(client):
assert calls.get("select") == "xyz"
def test_checksum_endpoints(client):
@pytest.mark.anyio
async def test_checksum_endpoints(client):
cl, token = client
calls = {}
api._pm.handle_verify_checksum = lambda: calls.setdefault("verify", True)
api._pm.handle_update_script_checksum = lambda: calls.setdefault("update", True)
api.app.state.pm.handle_verify_checksum = lambda: calls.setdefault("verify", True)
api.app.state.pm.handle_update_script_checksum = lambda: calls.setdefault(
"update", True
)
headers = {"Authorization": f"Bearer {token}"}
res = cl.post("/api/v1/checksum/verify", headers=headers)
res = await cl.post("/api/v1/checksum/verify", headers=headers)
assert res.status_code == 200
assert res.json() == {"status": "ok"}
assert calls.get("verify") is True
res = cl.post("/api/v1/checksum/update", headers=headers)
res = await cl.post("/api/v1/checksum/update", headers=headers)
assert res.status_code == 200
assert res.json() == {"status": "ok"}
assert calls.get("update") is True
def test_vault_import_via_path(client, tmp_path):
@pytest.mark.anyio
async def test_vault_import_via_path(client, tmp_path):
cl, token = client
called = {}
def import_db(path):
called["path"] = path
api._pm.handle_import_database = import_db
api._pm.fingerprint_dir = tmp_path # Mock fingerprint_dir for resolving relative paths
api._pm.sync_vault = lambda: called.setdefault("sync", True)
file_path = tmp_path / "b.json"
api.app.state.pm.handle_import_database = import_db
api.app.state.pm.sync_vault = lambda: called.setdefault("sync", True)
api.app.state.pm.encryption_manager = SimpleNamespace(
resolve_relative_path=lambda p: p
)
file_path = tmp_path / "b.json.enc"
file_path.write_text("{}")
headers = {"Authorization": f"Bearer {token}"}
# Pass relative path, which should resolve to file_path
res = cl.post(
res = await cl.post(
"/api/v1/vault/import",
json={"path": "b.json"},
json={"path": str(file_path)},
headers=headers,
)
assert res.status_code == 200
@@ -253,21 +258,22 @@ def test_vault_import_via_path(client, tmp_path):
assert called.get("sync") is True
def test_vault_import_via_upload(client, tmp_path):
@pytest.mark.anyio
async def test_vault_import_via_upload(client, tmp_path):
cl, token = client
called = {}
def import_db(path):
called["path"] = path
api._pm.handle_import_database = import_db
api._pm.sync_vault = lambda: called.setdefault("sync", True)
api.app.state.pm.handle_import_database = import_db
api.app.state.pm.sync_vault = lambda: called.setdefault("sync", True)
file_path = tmp_path / "c.json"
file_path.write_text("{}")
headers = {"Authorization": f"Bearer {token}"}
with open(file_path, "rb") as fh:
res = cl.post(
res = await cl.post(
"/api/v1/vault/import",
files={"file": ("c.json", fh.read())},
headers=headers,
@@ -278,29 +284,94 @@ def test_vault_import_via_upload(client, tmp_path):
assert called.get("sync") is True
def test_vault_lock_endpoint(client):
@pytest.mark.anyio
async def test_vault_import_upload_too_large(client):
cl, token = client
called = {}
api.app.state.pm.handle_import_database = lambda path: called.setdefault(
"import_called", True
)
api.app.state.pm.sync_vault = lambda: called.setdefault("sync_called", True)
old_limit = api._MAX_IMPORT_BYTES
api._MAX_IMPORT_BYTES = 8
try:
headers = {"Authorization": f"Bearer {token}"}
res = await cl.post(
"/api/v1/vault/import",
files={"file": ("big.json.enc", b"123456789")},
headers=headers,
)
finally:
api._MAX_IMPORT_BYTES = old_limit
assert res.status_code == 413
assert "exceeds max size" in res.json()["detail"]
assert called == {}
@pytest.mark.anyio
async def test_vault_import_invalid_extension(client):
cl, token = client
api.app.state.pm.handle_import_database = lambda path: None
api.app.state.pm.sync_vault = lambda: None
api.app.state.pm.encryption_manager = SimpleNamespace(
resolve_relative_path=lambda p: p
)
headers = {"Authorization": f"Bearer {token}"}
res = await cl.post(
"/api/v1/vault/import",
json={"path": "bad.txt"},
headers=headers,
)
assert res.status_code == 400
@pytest.mark.anyio
async def test_vault_import_path_traversal_blocked(client, tmp_path):
cl, token = client
key = base64.urlsafe_b64encode(os.urandom(32))
api.app.state.pm.encryption_manager = EncryptionManager(key, tmp_path)
api.app.state.pm.handle_import_database = lambda path: None
api.app.state.pm.sync_vault = lambda: None
headers = {"Authorization": f"Bearer {token}"}
res = await cl.post(
"/api/v1/vault/import",
json={"path": "../evil.json.enc"},
headers=headers,
)
assert res.status_code == 400
@pytest.mark.anyio
async def test_vault_lock_endpoint(client):
cl, token = client
called = {}
def lock():
called["locked"] = True
api._pm.locked = True
api.app.state.pm.locked = True
api._pm.lock_vault = lock
api._pm.locked = False
api.app.state.pm.lock_vault = lock
api.app.state.pm.locked = False
headers = {"Authorization": f"Bearer {token}"}
res = cl.post("/api/v1/vault/lock", headers=headers)
res = await cl.post("/api/v1/vault/lock", headers=headers)
assert res.status_code == 200
assert res.json() == {"status": "locked"}
assert called.get("locked") is True
assert api._pm.locked is True
api._pm.unlock_vault = lambda pw: setattr(api._pm, "locked", False)
api._pm.unlock_vault("pw")
assert api._pm.locked is False
assert api.app.state.pm.locked is True
api.app.state.pm.unlock_vault = lambda pw: setattr(
api.app.state.pm, "locked", False
)
api.app.state.pm.unlock_vault("pw")
assert api.app.state.pm.locked is False
def test_secret_mode_endpoint(client):
@pytest.mark.anyio
async def test_secret_mode_endpoint(client):
cl, token = client
called = {}
@@ -310,11 +381,11 @@ def test_secret_mode_endpoint(client):
def set_delay(val):
called.setdefault("delay", val)
api._pm.config_manager.set_secret_mode_enabled = set_secret
api._pm.config_manager.set_clipboard_clear_delay = set_delay
api.app.state.pm.config_manager.set_secret_mode_enabled = set_secret
api.app.state.pm.config_manager.set_clipboard_clear_delay = set_delay
headers = {"Authorization": f"Bearer {token}"}
res = cl.post(
res = await cl.post(
"/api/v1/secret-mode",
json={"enabled": True, "delay": 12},
headers=headers,
@@ -325,40 +396,79 @@ def test_secret_mode_endpoint(client):
assert called["delay"] == 12
def test_vault_export_endpoint(client, tmp_path):
@pytest.mark.anyio
async def test_vault_export_endpoint(client, tmp_path):
cl, token = client
out = tmp_path / "out.json"
out.write_text("data")
api._pm.handle_export_database = lambda: out
api.app.state.pm.handle_export_database = lambda *a, **k: out
headers = {"Authorization": f"Bearer {token}"}
res = cl.post("/api/v1/vault/export", headers=headers)
headers = {
"Authorization": f"Bearer {token}",
"X-SeedPass-Password": "pw",
}
res = await cl.post("/api/v1/vault/export", headers=headers)
assert res.status_code == 200
assert res.content == b"data"
res = await cl.post(
"/api/v1/vault/export", headers={"Authorization": f"Bearer {token}"}
)
assert res.status_code == 401
def test_backup_parent_seed_endpoint(client, tmp_path):
@pytest.mark.anyio
async def test_backup_parent_seed_endpoint(client, tmp_path):
cl, token = client
api.app.state.pm.parent_seed = "seed"
called = {}
api.app.state.pm.encryption_manager = SimpleNamespace(
encrypt_and_save_file=lambda data, path: called.setdefault("path", path),
resolve_relative_path=lambda p: p,
)
path = Path("seed.enc")
headers = {
"Authorization": f"Bearer {token}",
"X-SeedPass-Password": "pw",
}
res = await cl.post(
"/api/v1/vault/backup-parent-seed",
json={"path": str(path), "confirm": True},
headers=headers,
)
assert res.status_code == 200
assert res.json() == {"status": "saved", "path": str(path)}
assert called["path"] == path
def backup(path=None):
called["path"] = path
api._pm.handle_backup_reveal_parent_seed = backup
path = tmp_path / "seed.enc"
headers = {"Authorization": f"Bearer {token}"}
res = cl.post(
res = await cl.post(
"/api/v1/vault/backup-parent-seed",
json={"path": str(path)},
headers=headers,
)
assert res.status_code == 200
assert res.json() == {"status": "ok"}
assert called["path"] == path
assert res.status_code == 400
def test_relay_management_endpoints(client, dummy_nostr_client, monkeypatch):
@pytest.mark.anyio
async def test_backup_parent_seed_path_traversal_blocked(client, tmp_path):
cl, token = client
api.app.state.pm.parent_seed = "seed"
key = base64.urlsafe_b64encode(os.urandom(32))
api.app.state.pm.encryption_manager = EncryptionManager(key, tmp_path)
headers = {
"Authorization": f"Bearer {token}",
"X-SeedPass-Password": "pw",
}
res = await cl.post(
"/api/v1/vault/backup-parent-seed",
json={"path": "../evil.enc", "confirm": True},
headers=headers,
)
assert res.status_code == 400
@pytest.mark.anyio
async def test_relay_management_endpoints(client, dummy_nostr_client, monkeypatch):
cl, token = client
nostr_client, _ = dummy_nostr_client
relays = ["wss://a", "wss://b"]
@@ -371,8 +481,8 @@ def test_relay_management_endpoints(client, dummy_nostr_client, monkeypatch):
def set_relays(new, require_pin=False):
called["set"] = new
api._pm.config_manager.load_config = load_config
api._pm.config_manager.set_relays = set_relays
api.app.state.pm.config_manager.load_config = load_config
api.app.state.pm.config_manager.set_relays = set_relays
monkeypatch.setattr(
NostrClient,
"initialize_client_pool",
@@ -381,33 +491,34 @@ def test_relay_management_endpoints(client, dummy_nostr_client, monkeypatch):
monkeypatch.setattr(
nostr_client, "close_client_pool", lambda: called.setdefault("close", True)
)
api._pm.nostr_client = nostr_client
api._pm.nostr_client.relays = relays.copy()
api.app.state.pm.nostr_client = nostr_client
api.app.state.pm.nostr_client.relays = relays.copy()
headers = {"Authorization": f"Bearer {token}"}
res = cl.get("/api/v1/relays", headers=headers)
res = await cl.get("/api/v1/relays", headers=headers)
assert res.status_code == 200
assert res.json() == {"relays": relays}
res = cl.post("/api/v1/relays", json={"url": "wss://c"}, headers=headers)
res = await cl.post("/api/v1/relays", json={"url": "wss://c"}, headers=headers)
assert res.status_code == 200
assert called["set"] == ["wss://a", "wss://b", "wss://c"]
api._pm.config_manager.load_config = lambda require_pin=False: {
api.app.state.pm.config_manager.load_config = lambda require_pin=False: {
"relays": ["wss://a", "wss://b", "wss://c"]
}
res = cl.delete("/api/v1/relays/2", headers=headers)
res = await cl.delete("/api/v1/relays/2", headers=headers)
assert res.status_code == 200
assert called["set"] == ["wss://a", "wss://c"]
res = cl.post("/api/v1/relays/reset", headers=headers)
res = await cl.post("/api/v1/relays/reset", headers=headers)
assert res.status_code == 200
assert called.get("init") is True
assert api._pm.nostr_client.relays == list(DEFAULT_RELAYS)
assert api.app.state.pm.nostr_client.relays == list(DEFAULT_RELAYS)
def test_generate_password_no_special_chars(client):
@pytest.mark.anyio
async def test_generate_password_no_special_chars(client):
cl, token = client
class DummyEnc:
@@ -415,14 +526,18 @@ def test_generate_password_no_special_chars(client):
return b"\x00" * 32
class DummyBIP85:
def derive_entropy(self, index: int, bytes_len: int, app_no: int = 32) -> bytes:
return bytes(range(bytes_len))
def derive_entropy(
self, index: int, entropy_bytes: int, app_no: int = 32
) -> bytes:
return bytes(range(entropy_bytes))
api._pm.password_generator = PasswordGenerator(DummyEnc(), "seed", DummyBIP85())
api._pm.parent_seed = "seed"
api.app.state.pm.password_generator = PasswordGenerator(
DummyEnc(), "seed", DummyBIP85()
)
api.app.state.pm.parent_seed = "seed"
headers = {"Authorization": f"Bearer {token}"}
res = cl.post(
res = await cl.post(
"/api/v1/password",
json={"length": 16, "include_special_chars": False},
headers=headers,
@@ -432,7 +547,8 @@ def test_generate_password_no_special_chars(client):
assert not any(c in string.punctuation for c in pw)
def test_generate_password_allowed_chars(client):
@pytest.mark.anyio
async def test_generate_password_allowed_chars(client):
cl, token = client
class DummyEnc:
@@ -440,15 +556,19 @@ def test_generate_password_allowed_chars(client):
return b"\x00" * 32
class DummyBIP85:
def derive_entropy(self, index: int, bytes_len: int, app_no: int = 32) -> bytes:
return bytes((index + i) % 256 for i in range(bytes_len))
def derive_entropy(
self, index: int, entropy_bytes: int, app_no: int = 32
) -> bytes:
return bytes((index + i) % 256 for i in range(entropy_bytes))
api._pm.password_generator = PasswordGenerator(DummyEnc(), "seed", DummyBIP85())
api._pm.parent_seed = "seed"
api.app.state.pm.password_generator = PasswordGenerator(
DummyEnc(), "seed", DummyBIP85()
)
api.app.state.pm.parent_seed = "seed"
headers = {"Authorization": f"Bearer {token}"}
allowed = "@$"
res = cl.post(
res = await cl.post(
"/api/v1/password",
json={"length": 16, "allowed_special_chars": allowed},
headers=headers,
+34 -21
View File
@@ -1,45 +1,58 @@
from test_api import client
from types import SimpleNamespace
import queue
import pytest
import seedpass.api as api
def test_notifications_endpoint(client):
@pytest.mark.anyio
async def test_notifications_endpoint(client):
cl, token = client
api._pm.notifications = queue.Queue()
api._pm.notifications.put(SimpleNamespace(message="m1", level="INFO"))
api._pm.notifications.put(SimpleNamespace(message="m2", level="WARNING"))
res = cl.get("/api/v1/notifications", headers={"Authorization": f"Bearer {token}"})
api.app.state.pm.notifications = queue.Queue()
api.app.state.pm.notifications.put(SimpleNamespace(message="m1", level="INFO"))
api.app.state.pm.notifications.put(SimpleNamespace(message="m2", level="WARNING"))
res = await cl.get(
"/api/v1/notifications", headers={"Authorization": f"Bearer {token}"}
)
assert res.status_code == 200
assert res.json() == [
{"level": "INFO", "message": "m1"},
{"level": "WARNING", "message": "m2"},
]
assert api._pm.notifications.empty()
assert api.app.state.pm.notifications.empty()
def test_notifications_endpoint_clears_queue(client):
@pytest.mark.anyio
async def test_notifications_endpoint_clears_queue(client):
cl, token = client
api._pm.notifications = queue.Queue()
api._pm.notifications.put(SimpleNamespace(message="hi", level="INFO"))
res = cl.get("/api/v1/notifications", headers={"Authorization": f"Bearer {token}"})
api.app.state.pm.notifications = queue.Queue()
api.app.state.pm.notifications.put(SimpleNamespace(message="hi", level="INFO"))
res = await cl.get(
"/api/v1/notifications", headers={"Authorization": f"Bearer {token}"}
)
assert res.status_code == 200
assert res.json() == [{"level": "INFO", "message": "hi"}]
assert api._pm.notifications.empty()
res = cl.get("/api/v1/notifications", headers={"Authorization": f"Bearer {token}"})
assert api.app.state.pm.notifications.empty()
res = await cl.get(
"/api/v1/notifications", headers={"Authorization": f"Bearer {token}"}
)
assert res.json() == []
def test_notifications_endpoint_does_not_clear_current(client):
@pytest.mark.anyio
async def test_notifications_endpoint_does_not_clear_current(client):
cl, token = client
api._pm.notifications = queue.Queue()
api.app.state.pm.notifications = queue.Queue()
msg = SimpleNamespace(message="keep", level="INFO")
api._pm.notifications.put(msg)
api._pm._current_notification = msg
api._pm.get_current_notification = lambda: api._pm._current_notification
api.app.state.pm.notifications.put(msg)
api.app.state.pm._current_notification = msg
api.app.state.pm.get_current_notification = (
lambda: api.app.state.pm._current_notification
)
res = cl.get("/api/v1/notifications", headers={"Authorization": f"Bearer {token}"})
res = await cl.get(
"/api/v1/notifications", headers={"Authorization": f"Bearer {token}"}
)
assert res.status_code == 200
assert res.json() == [{"level": "INFO", "message": "keep"}]
assert api._pm.notifications.empty()
assert api._pm.get_current_notification() is msg
assert api.app.state.pm.notifications.empty()
assert api.app.state.pm.get_current_notification() is msg
+5 -5
View File
@@ -1,13 +1,13 @@
from test_api import client
import pytest
def test_profile_stats_endpoint(client):
@pytest.mark.anyio
async def test_profile_stats_endpoint(client):
cl, token = client
stats = {"total_entries": 1}
# monkeypatch set _pm.get_profile_stats after client fixture started
import seedpass.api as api
api._pm.get_profile_stats = lambda: stats
res = cl.get("/api/v1/stats", headers={"Authorization": f"Bearer {token}"})
api.app.state.pm.get_profile_stats = lambda: stats
res = await cl.get("/api/v1/stats", headers={"Authorization": f"Bearer {token}"})
assert res.status_code == 200
assert res.json() == stats
+50
View File
@@ -0,0 +1,50 @@
import importlib
from pathlib import Path
from types import SimpleNamespace
import importlib
import pytest
from httpx import ASGITransport, AsyncClient
import sys
sys.path.append(str(Path(__file__).resolve().parents[1]))
@pytest.mark.anyio
async def test_rate_limit_exceeded(monkeypatch):
monkeypatch.setenv("SEEDPASS_RATE_LIMIT", "2")
monkeypatch.setenv("SEEDPASS_RATE_WINDOW", "60")
import seedpass.api as api
importlib.reload(api)
dummy = SimpleNamespace(
entry_manager=SimpleNamespace(
search_entries=lambda q: [
(1, "Site", "user", "url", False, SimpleNamespace(value="password"))
]
),
config_manager=SimpleNamespace(load_config=lambda require_pin=False: {}),
fingerprint_manager=SimpleNamespace(list_fingerprints=lambda: []),
nostr_client=SimpleNamespace(
key_manager=SimpleNamespace(get_npub=lambda: "np")
),
verify_password=lambda pw: True,
)
monkeypatch.setattr(api, "PasswordManager", lambda: dummy)
token = api.start_server()
transport = ASGITransport(app=api.app)
async with AsyncClient(transport=transport, base_url="http://test") as client:
headers = {"Authorization": f"Bearer {token}"}
for _ in range(2):
res = await client.get(
"/api/v1/entry", params={"query": "s"}, headers=headers
)
assert res.status_code == 200
res = await client.get("/api/v1/entry", params={"query": "s"}, headers=headers)
assert res.status_code == 429
bucket_keys = list(api.app.state.rate_limit_buckets.keys())
assert bucket_keys
assert all(token not in key for key in bucket_keys)
+29
View File
@@ -0,0 +1,29 @@
import logging
from types import SimpleNamespace
from seedpass import api
def test_reload_relays_logs_errors(caplog):
def close():
raise RuntimeError("close fail")
def init():
raise OSError("init fail")
pm = SimpleNamespace(
nostr_client=SimpleNamespace(
close_client_pool=close,
initialize_client_pool=init,
relays=[],
)
)
request = SimpleNamespace(app=SimpleNamespace(state=SimpleNamespace(pm=pm)))
with caplog.at_level(logging.WARNING):
api._reload_relays(request, ["ws://relay"])
assert "Failed to close NostrClient pool" in caplog.text
assert "close fail" in caplog.text
assert "Failed to initialize NostrClient with relays" in caplog.text
assert "init fail" in caplog.text
+41
View File
@@ -193,3 +193,44 @@ def test_archived_entries_menu_hides_active(monkeypatch, capsys):
out = capsys.readouterr().out
assert "archived.com" in out
assert "active.com" not in out
def test_view_archived_entries_refreshes_after_view_action(monkeypatch):
with TemporaryDirectory() as tmpdir:
tmp_path = Path(tmpdir)
vault, enc_mgr = create_vault(tmp_path, TEST_SEED, TEST_PASSWORD)
cfg_mgr = ConfigManager(vault, tmp_path)
backup_mgr = BackupManager(tmp_path, cfg_mgr)
entry_mgr = EntryManager(vault, backup_mgr)
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.is_dirty = False
pm.notifications = queue.Queue()
idx = entry_mgr.add_entry("example.com", 8)
entry_mgr.archive_entry(idx)
assert entry_mgr.retrieve_entry(idx)["archived"] is True
monkeypatch.setattr(
pm,
"show_entry_details_by_index",
lambda index: entry_mgr.restore_entry(index),
)
monkeypatch.setattr("seedpass.core.manager.pause", lambda: None)
inputs = iter([str(idx), "v"])
monkeypatch.setattr("builtins.input", lambda *_: next(inputs))
pm.handle_view_archived_entries()
assert entry_mgr.retrieve_entry(idx)["archived"] is False
note = pm.notifications.get_nowait()
assert note.level == "WARNING"
assert note.message == "No archived entries found."
+30
View File
@@ -0,0 +1,30 @@
import json
from multiprocessing import Process
from pathlib import Path
from utils.atomic_write import atomic_write
def _writer(path: Path, content: dict, loops: int) -> None:
for _ in range(loops):
atomic_write(path, lambda f: json.dump(content, f), mode="w")
def test_atomic_write_concurrent(tmp_path: Path) -> None:
"""Concurrent writers should not leave partial files."""
file_path = tmp_path / "data.json"
contents = [{"proc": i} for i in range(5)]
procs = [
Process(target=_writer, args=(file_path, content, 50)) for content in contents
]
for p in procs:
p.start()
for p in procs:
p.join()
final_text = file_path.read_text()
final_obj = json.loads(final_text)
assert final_obj in contents
+85
View File
@@ -0,0 +1,85 @@
import json
import hashlib
import hmac
import queue
from pathlib import Path
from types import SimpleNamespace
import importlib
import pytest
from seedpass.core.manager import PasswordManager, AuditLogger
import seedpass.core.manager as manager_module
def test_audit_logger_records_events(monkeypatch, tmp_path):
monkeypatch.setattr(Path, "home", lambda: tmp_path)
pm = PasswordManager.__new__(PasswordManager)
pm.fingerprint_dir = tmp_path
pm.current_fingerprint = "user123"
pm.profile_stack = []
pm.setup_encryption_manager = lambda *a, **k: None
pm.initialize_bip85 = lambda: None
pm.initialize_managers = lambda: None
pm.update_activity = lambda: None
pm.verify_password = lambda pw: True
pm.notifications = queue.Queue()
pm.parent_seed = "seed phrase"
pm.config_manager = SimpleNamespace(get_quick_unlock=lambda: True)
manager_module.clear_header_with_notification = lambda *a, **k: None
pm.unlock_vault(password="pw")
dest = tmp_path / "db.json.enc"
monkeypatch.setattr(manager_module, "export_backup", lambda *a, **k: dest)
pm.vault = object()
pm.backup_manager = object()
monkeypatch.setattr("seedpass.core.manager.confirm_action", lambda *_a, **_k: True)
pm.handle_export_database(dest)
confirms = iter([True, False])
monkeypatch.setattr(
"seedpass.core.manager.confirm_action", lambda *_a, **_k: next(confirms)
)
pm.encryption_manager = SimpleNamespace(encrypt_and_save_file=lambda *a, **k: None)
pm.handle_backup_reveal_parent_seed(password="pw")
log_path = tmp_path / ".seedpass" / "audit.log"
lines = [json.loads(l) for l in log_path.read_text().splitlines()]
events = [e["event"] for e in lines]
assert "quick_unlock" in events
assert "backup_export" in events
assert "seed_reveal" in events
def _verify_chain(path: Path, key: bytes) -> bool:
prev = "0" * 64
for line in path.read_text().splitlines():
data = json.loads(line)
sig = data.pop("sig")
payload = json.dumps(data, sort_keys=True, separators=(",", ":"))
expected = hmac.new(
key, f"{prev}{payload}".encode(), hashlib.sha256
).hexdigest()
if sig != expected:
return False
prev = sig
return True
def test_audit_log_tamper_evident(monkeypatch, tmp_path):
monkeypatch.setattr(Path, "home", lambda: tmp_path)
key = hashlib.sha256(b"seed").digest()
logger = AuditLogger(key)
logger.log("one", {})
logger.log("two", {})
log_path = tmp_path / ".seedpass" / "audit.log"
assert _verify_chain(log_path, key)
lines = log_path.read_text().splitlines()
tampered = json.loads(lines[0])
tampered["event"] = "evil"
lines[0] = json.dumps(tampered)
log_path.write_text("\n".join(lines) + "\n")
assert not _verify_chain(log_path, key)
+1
View File
@@ -15,6 +15,7 @@ def test_auto_sync_triggers_post(monkeypatch):
is_dirty=True,
last_update=time.time() - 0.2,
last_activity=time.time(),
current_fingerprint="fp",
nostr_client=SimpleNamespace(close_client_pool=lambda: None),
handle_add_password=lambda: None,
handle_retrieve_entry=lambda: None,
@@ -0,0 +1,56 @@
import logging
import queue
import seedpass.core.manager as manager_module
def _make_pm():
pm = manager_module.PasswordManager.__new__(manager_module.PasswordManager)
pm.offline_mode = False
pm.notifications = queue.Queue()
pm.error_queue = queue.Queue()
pm.notify = lambda msg, level="INFO": pm.notifications.put(
manager_module.Notification(msg, level)
)
pm.nostr_client = object()
return pm
def test_start_background_sync_error(monkeypatch, caplog):
pm = _make_pm()
async def failing_sync(*_args, **_kwargs):
raise RuntimeError("boom")
monkeypatch.setattr(pm, "attempt_initial_sync_async", failing_sync)
monkeypatch.setattr(pm, "sync_index_from_nostr_async", failing_sync)
pm.start_background_sync()
pm._sync_task.join(timeout=1)
with caplog.at_level(logging.WARNING):
pm.poll_background_errors()
note = pm.notifications.get_nowait()
assert "boom" in note.message
assert "boom" in caplog.text
def test_start_background_relay_check_error(monkeypatch, caplog):
pm = _make_pm()
class DummyClient:
def check_relay_health(self, *_args, **_kwargs):
raise RuntimeError("relay boom")
pm.nostr_client = DummyClient()
pm.start_background_relay_check()
pm._relay_thread.join(timeout=1)
with caplog.at_level(logging.WARNING):
pm.poll_background_errors()
note = pm.notifications.get_nowait()
assert "relay boom" in note.message
assert "relay boom" in caplog.text
+1
View File
@@ -20,6 +20,7 @@ def test_switch_fingerprint_triggers_bg_sync(monkeypatch, tmp_path):
pm.current_fingerprint = None
pm.encryption_manager = object()
pm.config_manager = SimpleNamespace(get_quick_unlock=lambda: False)
pm.nostr_account_idx = 0
monkeypatch.setattr("builtins.input", lambda *_a, **_k: "1")
monkeypatch.setattr(
@@ -0,0 +1,58 @@
import asyncio
from seedpass.core.manager import PasswordManager
from seedpass.core import manager as manager_module
def test_start_background_vault_sync_thread_worker_path(monkeypatch):
pm = PasswordManager.__new__(PasswordManager)
pm.offline_mode = False
events = []
async def fake_sync_vault_async(*, alt_summary=None):
return {"alt_summary": alt_summary}
monkeypatch.setattr(pm, "sync_vault_async", fake_sync_vault_async)
monkeypatch.setattr(
manager_module.bus, "publish", lambda *args: events.append(args)
)
class ImmediateThread:
def __init__(self, target, daemon):
self._target = target
self.daemon = daemon
def start(self):
self._target()
monkeypatch.setattr(manager_module.threading, "Thread", ImmediateThread)
pm.start_background_vault_sync("thread-path")
assert ("sync_started",) in events
assert ("sync_finished", {"alt_summary": "thread-path"}) in events
def test_start_background_vault_sync_async_worker_path(monkeypatch):
pm = PasswordManager.__new__(PasswordManager)
pm.offline_mode = False
events = []
async def fake_sync_vault_async(*, alt_summary=None):
return {"alt_summary": alt_summary}
monkeypatch.setattr(pm, "sync_vault_async", fake_sync_vault_async)
monkeypatch.setattr(
manager_module.bus, "publish", lambda *args: events.append(args)
)
async def runner():
pm.start_background_vault_sync("async-path")
await asyncio.sleep(0.02)
asyncio.run(runner())
assert ("sync_started",) in events
assert ("sync_finished", {"alt_summary": "async-path"}) in events
+56
View File
@@ -0,0 +1,56 @@
import main
from pathlib import Path
def test_cli_flag_restores_before_init(monkeypatch, tmp_path):
calls = []
backup = tmp_path / "bak.json"
backup.write_text("{}")
def fake_restore(path, fingerprint):
calls.append(("restore", Path(path), fingerprint))
class DummyPM:
def __init__(self, fingerprint=None):
calls.append(("init", fingerprint))
self.secret_mode_enabled = True
self.inactivity_timeout = 0
monkeypatch.setattr(main, "restore_backup_index", fake_restore)
monkeypatch.setattr(main, "PasswordManager", DummyPM)
monkeypatch.setattr(main, "display_menu", lambda pm, **k: None)
rc = main.main(["--fingerprint", "fp", "--restore-backup", str(backup)])
assert rc == 0
assert calls[0][0] == "restore"
assert calls[1][0] == "init"
assert calls[0][1] == backup
assert calls[0][2] == "fp"
def test_menu_option_restores_before_init(monkeypatch, tmp_path):
calls = []
backup = tmp_path / "bak.json"
backup.write_text("{}")
def fake_restore(path, fingerprint):
calls.append(("restore", Path(path), fingerprint))
class DummyPM:
def __init__(self, fingerprint=None):
calls.append(("init", fingerprint))
self.secret_mode_enabled = True
self.inactivity_timeout = 0
monkeypatch.setattr(main, "restore_backup_index", fake_restore)
monkeypatch.setattr(main, "PasswordManager", DummyPM)
monkeypatch.setattr(main, "display_menu", lambda pm, **k: None)
inputs = iter(["2", str(backup)])
monkeypatch.setattr("builtins.input", lambda _prompt="": next(inputs))
rc = main.main(["--fingerprint", "fp"])
assert rc == 0
assert calls[0][0] == "restore"
assert calls[1][0] == "init"
assert calls[0][1] == backup
assert calls[0][2] == "fp"
+52
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'"
+39
View File
@@ -0,0 +1,39 @@
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
from seedpass.core.manager import PasswordManager
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)
def test_initialize_bip85_works_while_locked():
pm = PasswordManager.__new__(PasswordManager)
pm.parent_seed = TEST_SEED
pm.is_locked = True
pm._bip85_cache = {(39, 0): b"stale"}
pm.KEY_STORAGE = None
pm.KEY_INDEX = None
pm.KEY_PW_DERIVE = None
pm.KEY_TOTP_DET = None
pm.master_key = None
pm.initialize_bip85()
assert isinstance(pm.bip85, BIP85)
assert pm._bip85_cache == {}
+84
View File
@@ -0,0 +1,84 @@
from __future__ import annotations
import importlib.util
import sys
from pathlib import Path
import pytest
def _load_module():
repo_root = Path(__file__).resolve().parents[2]
script_path = repo_root / "scripts" / "check_critical_coverage.py"
spec = importlib.util.spec_from_file_location(
"check_critical_coverage", script_path
)
assert spec is not None and spec.loader is not None
module = importlib.util.module_from_spec(spec)
sys.modules[spec.name] = module
spec.loader.exec_module(module)
return module
coverage_gate = _load_module()
def test_parse_threshold_overrides_valid():
parsed = coverage_gate._parse_threshold_overrides(
["src/main.py=40", "src/seedpass/core/manager.py=55.5"]
)
assert parsed["src/main.py"] == 40.0
assert parsed["src/seedpass/core/manager.py"] == 55.5
@pytest.mark.parametrize(
"value",
[
"src/main.py",
"src/main.py=abc",
"src/main.py=-1",
"src/main.py=101",
"=50",
],
)
def test_parse_threshold_overrides_invalid(value):
with pytest.raises(ValueError):
coverage_gate._parse_threshold_overrides([value])
def test_find_file_payload_accepts_absolute_path_suffix():
files_payload = {
"/tmp/work/src/main.py": {"summary": {"percent_covered": 50.0}},
"/tmp/work/src/seedpass/core/manager.py": {
"summary": {"percent_covered": 60.0}
},
}
payload = coverage_gate._find_file_payload(files_payload, "src/main.py")
assert payload == {"summary": {"percent_covered": 50.0}}
def test_compute_function_coverage_reports_uncovered(tmp_path):
module_file = tmp_path / "example.py"
module_file.write_text(
"\n".join(
[
"def covered():",
" return 1",
"",
"def uncovered():",
" return 2",
"",
"class A:",
" def method(self):",
" return 3",
]
),
encoding="utf-8",
)
coverage = coverage_gate._compute_function_coverage(
module_file, executed_lines={1, 2}
)
by_name = {item.name: item for item in coverage}
assert by_name["covered"].covered is True
assert by_name["uncovered"].covered is False
assert by_name["A.method"].covered is False
+290
View File
@@ -0,0 +1,290 @@
import json
import sys
from pathlib import Path
from types import SimpleNamespace
from typer.testing import CliRunner
sys.path.append(str(Path(__file__).resolve().parents[1]))
from seedpass.cli import app
from seedpass.cli import agent as agent_cli
from seedpass.core.entry_types import EntryType
runner = CliRunner()
def test_agent_policy_show_defaults(monkeypatch, tmp_path):
monkeypatch.setattr(agent_cli, "APP_DIR", tmp_path)
result = runner.invoke(app, ["agent", "policy-show"])
assert result.exit_code == 0
payload = json.loads(result.stdout)
assert payload["allow_export_import"] is False
assert EntryType.PASSWORD.value in payload["allow_kinds"]
def test_agent_policy_set_persists(monkeypatch, tmp_path):
monkeypatch.setattr(agent_cli, "APP_DIR", tmp_path)
result = runner.invoke(
app,
[
"agent",
"policy-set",
"--allow-kind",
"password",
"--allow-kind",
"totp",
"--deny-private-kind",
"seed",
"--allow-export-import",
],
)
assert result.exit_code == 0
payload = json.loads(result.stdout)
assert payload["allow_kinds"] == ["password", "totp"]
assert payload["deny_private_reveal"] == ["seed"]
assert payload["allow_export_import"] is True
def test_agent_init_creates_profile(monkeypatch, tmp_path):
monkeypatch.setattr(agent_cli, "APP_DIR", tmp_path)
monkeypatch.setenv("SEEDPASS_PASSWORD", "AgentPass123!")
result = runner.invoke(
app,
[
"agent",
"init",
"--seed",
"abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon about",
],
)
assert result.exit_code == 0
payload = json.loads(result.stdout)
assert payload["status"] == "ok"
assert payload["fingerprint"]
profile_dir = Path(payload["profile_dir"])
assert profile_dir.exists()
assert (profile_dir / "parent_seed.enc").exists()
def test_agent_init_requires_password_env(monkeypatch, tmp_path):
monkeypatch.setattr(agent_cli, "APP_DIR", tmp_path)
monkeypatch.delenv("SEEDPASS_PASSWORD", raising=False)
result = runner.invoke(
app,
[
"agent",
"init",
"--seed",
"abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon about",
],
)
assert result.exit_code == 2
combined = f"{result.stdout}{result.stderr}"
assert "Missing password env var" in combined
def test_agent_get_requires_fingerprint(monkeypatch):
monkeypatch.setenv("SEEDPASS_PASSWORD", "pw")
result = runner.invoke(app, ["agent", "get", "example"])
assert result.exit_code == 2
def test_agent_get_password_json(monkeypatch):
monkeypatch.setenv("SEEDPASS_PASSWORD", "pw")
monkeypatch.setattr(
agent_cli,
"PasswordManager",
lambda fingerprint, password: SimpleNamespace(
fingerprint=fingerprint, password=password
),
)
class DummyEntryService:
def __init__(self, _pm):
pass
def search_entries(self, _q):
return [(2, "Example", "", "", False, EntryType.PASSWORD)]
def retrieve_entry(self, _i):
return {"type": EntryType.PASSWORD.value, "label": "Example", "length": 12}
def generate_password(self, _length, _index):
return "secret-pass"
monkeypatch.setattr(agent_cli, "EntryService", DummyEntryService)
monkeypatch.setattr(
agent_cli,
"_load_policy",
lambda **kwargs: {"allow_kinds": ["password"], "deny_private_reveal": []},
)
result = runner.invoke(app, ["--fingerprint", "ABC123", "agent", "get", "example"])
assert result.exit_code == 0
payload = json.loads(result.stdout)
assert payload["status"] == "ok"
assert payload["fingerprint"] == "ABC123"
assert payload["secret"] == "secret-pass"
assert payload["kind"] == "password"
def test_agent_get_denied_by_policy(monkeypatch):
monkeypatch.setenv("SEEDPASS_PASSWORD", "pw")
monkeypatch.setattr(
agent_cli,
"PasswordManager",
lambda fingerprint, password: SimpleNamespace(
fingerprint=fingerprint, password=password
),
)
class DummyEntryService:
def __init__(self, _pm):
pass
def search_entries(self, _q):
return [(4, "Seed", "", "", False, EntryType.SEED)]
def retrieve_entry(self, _i):
return {"kind": EntryType.SEED.value, "label": "Seed"}
monkeypatch.setattr(agent_cli, "EntryService", DummyEntryService)
monkeypatch.setattr(
agent_cli,
"_load_policy",
lambda **kwargs: {
"allow_kinds": ["password", "totp", "key_value"],
"deny_private_reveal": ["seed"],
},
)
result = runner.invoke(app, ["--fingerprint", "ABC123", "agent", "get", "seed"])
assert result.exit_code == 1
payload = json.loads(result.stdout)
assert payload["status"] == "denied"
assert payload["reason"] in {"kind_not_allowed", "private_kind_blocked"}
def test_agent_get_ambiguous_returns_error_payload(monkeypatch):
monkeypatch.setenv("SEEDPASS_PASSWORD", "pw")
monkeypatch.setattr(
agent_cli,
"PasswordManager",
lambda fingerprint, password: SimpleNamespace(
fingerprint=fingerprint, password=password
),
)
class DummyEntryService:
def __init__(self, _pm):
pass
def search_entries(self, _q):
return [
(2, "Example1", "", "", False, EntryType.PASSWORD),
(3, "Example2", "", "", False, EntryType.PASSWORD),
]
monkeypatch.setattr(agent_cli, "EntryService", DummyEntryService)
monkeypatch.setattr(
agent_cli,
"_load_policy",
lambda **kwargs: {"allow_kinds": ["password"], "deny_private_reveal": []},
)
result = runner.invoke(app, ["--fingerprint", "ABC123", "agent", "get", "example"])
assert result.exit_code == 1
payload = json.loads(result.stdout)
assert payload["status"] == "error"
assert payload["reason"] == "ambiguous_or_missing"
assert payload["match_count"] == 2
def test_agent_get_key_value_json(monkeypatch):
monkeypatch.setenv("SEEDPASS_PASSWORD", "pw")
monkeypatch.setattr(
agent_cli,
"PasswordManager",
lambda fingerprint, password: SimpleNamespace(
fingerprint=fingerprint, password=password
),
)
class DummyEntryService:
def __init__(self, _pm):
pass
def search_entries(self, _q):
return [(2, "Key", "", "", False, EntryType.KEY_VALUE)]
def retrieve_entry(self, _i):
return {"kind": EntryType.KEY_VALUE.value, "label": "Key", "value": "v123"}
monkeypatch.setattr(agent_cli, "EntryService", DummyEntryService)
monkeypatch.setattr(
agent_cli,
"_load_policy",
lambda **kwargs: {"allow_kinds": ["key_value"], "deny_private_reveal": []},
)
result = runner.invoke(app, ["--fingerprint", "ABC123", "agent", "get", "key"])
assert result.exit_code == 0
payload = json.loads(result.stdout)
assert payload["status"] == "ok"
assert payload["kind"] == "key_value"
assert payload["secret"] == "v123"
def test_agent_get_unsupported_kind_payload(monkeypatch):
monkeypatch.setenv("SEEDPASS_PASSWORD", "pw")
monkeypatch.setattr(
agent_cli,
"PasswordManager",
lambda fingerprint, password: SimpleNamespace(
fingerprint=fingerprint, password=password
),
)
class DummyEntryService:
def __init__(self, _pm):
pass
def search_entries(self, _q):
return [(4, "SSH", "", "", False, EntryType.SSH)]
def retrieve_entry(self, _i):
return {"kind": EntryType.SSH.value, "label": "SSH"}
monkeypatch.setattr(agent_cli, "EntryService", DummyEntryService)
monkeypatch.setattr(
agent_cli,
"_load_policy",
lambda **kwargs: {"allow_kinds": ["ssh"], "deny_private_reveal": []},
)
result = runner.invoke(app, ["--fingerprint", "ABC123", "agent", "get", "ssh"])
assert result.exit_code == 1
payload = json.loads(result.stdout)
assert payload["status"] == "error"
assert payload["reason"] == "unsupported_kind_for_agent_get"
def test_agent_policy_show_invalid_json(monkeypatch, tmp_path):
monkeypatch.setattr(agent_cli, "APP_DIR", tmp_path)
(tmp_path / "agent_policy.json").write_text("{", encoding="utf-8")
result = runner.invoke(app, ["agent", "policy-show"])
assert result.exit_code == 2
combined = f"{result.stdout}{result.stderr}"
assert "not valid JSON" in combined
def test_agent_get_denied_when_policy_invalid(monkeypatch, tmp_path):
monkeypatch.setattr(agent_cli, "APP_DIR", tmp_path)
(tmp_path / "agent_policy.json").write_text("{", encoding="utf-8")
monkeypatch.setenv("SEEDPASS_PASSWORD", "pw")
result = runner.invoke(app, ["--fingerprint", "ABC123", "agent", "get", "example"])
assert result.exit_code == 1
payload = json.loads(result.stdout)
assert payload["status"] == "denied"
assert payload["reason"] == "invalid_policy"
+43
View File
@@ -0,0 +1,43 @@
from typer.testing import CliRunner
from seedpass.cli import app, entry as cli_entry
from seedpass.core.entry_types import EntryType
from utils.clipboard import ClipboardUnavailableError
runner = CliRunner()
def _stub_service(ctx, raise_error=True):
class Service:
def search_entries(self, query, kinds=None):
return [(1, "label", None, None, False, EntryType.PASSWORD)]
def retrieve_entry(self, idx):
return {"type": EntryType.PASSWORD.value, "length": 12}
def generate_password(self, length, index):
if raise_error and not ctx.obj.get("no_clipboard"):
raise ClipboardUnavailableError("missing")
return "pwd"
return Service()
def test_entry_get_handles_missing_clipboard(monkeypatch):
monkeypatch.setattr(
cli_entry, "_get_entry_service", lambda ctx: _stub_service(ctx, True)
)
result = runner.invoke(app, ["entry", "get", "label"], catch_exceptions=False)
assert result.exit_code == 1
assert "no-clipboard" in result.stderr.lower()
def test_entry_get_no_clipboard_flag(monkeypatch):
monkeypatch.setattr(
cli_entry, "_get_entry_service", lambda ctx: _stub_service(ctx, True)
)
result = runner.invoke(
app, ["--no-clipboard", "entry", "get", "label"], catch_exceptions=False
)
assert result.exit_code == 0
assert "pwd" in result.stdout
+2 -2
View File
@@ -3,7 +3,7 @@ from types import SimpleNamespace
from typer.testing import CliRunner
from seedpass.cli import app
from seedpass import cli
from seedpass.cli import common as cli_common
runner = CliRunner()
@@ -39,7 +39,7 @@ def test_config_set_variants(monkeypatch, key, value, method, expected):
config_manager=SimpleNamespace(**{method: func}),
select_fingerprint=lambda fp: None,
)
monkeypatch.setattr(cli, "PasswordManager", lambda: pm)
monkeypatch.setattr(cli_common, "PasswordManager", lambda: pm)
result = runner.invoke(app, ["config", "set", key, value])
+3 -2
View File
@@ -5,6 +5,7 @@ from typer.testing import CliRunner
from seedpass import cli
from seedpass.cli import app
from seedpass.cli import common as cli_common
from seedpass.core.entry_types import EntryType
runner = CliRunner()
@@ -18,7 +19,7 @@ def test_cli_vault_unlock(monkeypatch):
return 0.5
pm = SimpleNamespace(unlock_vault=unlock_vault, select_fingerprint=lambda fp: None)
monkeypatch.setattr(cli, "PasswordManager", lambda: pm)
monkeypatch.setattr(cli_common, "PasswordManager", lambda: pm)
monkeypatch.setattr(cli.typer, "prompt", lambda *a, **k: "pw")
result = runner.invoke(app, ["vault", "unlock"])
assert result.exit_code == 0
@@ -49,7 +50,7 @@ def test_cli_entry_add_search_sync(monkeypatch):
sync_vault=lambda: {"manifest_id": "m", "chunk_ids": [], "delta_ids": []},
select_fingerprint=lambda fp: None,
)
monkeypatch.setattr(cli, "PasswordManager", lambda: pm)
monkeypatch.setattr(cli_common, "PasswordManager", lambda: pm)
# entry add
result = runner.invoke(app, ["entry", "add", "Label"])
+9 -11
View File
@@ -8,13 +8,15 @@ sys.path.append(str(Path(__file__).resolve().parents[1] / "src"))
from typer.testing import CliRunner
from seedpass import cli
from seedpass.cli import common as cli_common
from seedpass.cli import api as cli_api
from seedpass.core.entry_types import EntryType
class DummyPM:
def __init__(self):
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)
],
search_entries=lambda q, kinds=None: [
@@ -23,7 +25,7 @@ class DummyPM:
retrieve_entry=lambda idx: {"type": EntryType.PASSWORD.value, "length": 8},
get_totp_code=lambda idx, seed: "123456",
add_entry=lambda label, length, username, url, **kwargs: 1,
add_totp=lambda label, seed, index=None, secret=None, period=30, digits=6: "totp://",
add_totp=lambda label, seed, index=None, secret=None, period=30, digits=6, deterministic=False: "totp://",
add_ssh_key=lambda label, seed, index=None, notes="": 2,
add_pgp_key=lambda label, seed, index=None, key_type="ed25519", user_id="", notes="": 3,
add_nostr_key=lambda label, seed, index=None, notes="": 4,
@@ -40,7 +42,7 @@ class DummyPM:
)
self.parent_seed = "seed"
self.handle_display_totp_codes = lambda: None
self.handle_export_database = lambda path: None
self.handle_export_database = lambda path, **kwargs: None
self.handle_import_database = lambda path: None
self.change_password = lambda *a, **kw: None
self.lock_vault = lambda: None
@@ -75,7 +77,7 @@ class DummyPM:
set_offline_mode=lambda v: None,
get_secret_mode_enabled=lambda: True,
get_clipboard_clear_delay=lambda: 30,
get_offline_mode=lambda: False,
get_offline_mode=lambda: True,
)
self.secret_mode_enabled = True
self.clipboard_clear_delay = 30
@@ -97,13 +99,9 @@ runner = CliRunner()
def _setup(monkeypatch):
monkeypatch.setattr(cli, "PasswordManager", lambda: DummyPM())
monkeypatch.setattr(cli.uvicorn, "run", lambda *a, **kw: None)
monkeypatch.setattr(
cli.api_module,
"start_server",
lambda fingerprint=None, token=None: "token",
)
monkeypatch.setattr(cli_common, "PasswordManager", lambda: DummyPM())
monkeypatch.setattr(cli_api.uvicorn, "run", lambda *a, **kw: None)
monkeypatch.setattr(cli_api.api_module, "start_server", lambda fp: "token")
monkeypatch.setitem(
sys.modules, "requests", SimpleNamespace(post=lambda *a, **kw: None)
)
+10 -4
View File
@@ -3,7 +3,7 @@ from types import SimpleNamespace
from typer.testing import CliRunner
from seedpass.cli import app
from seedpass import cli
from seedpass.cli import common as cli_common
from helpers import TEST_SEED
runner = CliRunner()
@@ -65,8 +65,14 @@ runner = CliRunner()
"--digits",
"7",
],
("Label", "seed"),
{"index": 1, "secret": "abc", "period": 45, "digits": 7},
("Label", None),
{
"index": 1,
"secret": "abc",
"period": 45,
"digits": 7,
"deterministic": False,
},
"otpauth://uri",
),
(
@@ -148,7 +154,7 @@ def test_entry_add_commands(
select_fingerprint=lambda fp: None,
start_background_vault_sync=start_background_vault_sync,
)
monkeypatch.setattr(cli, "PasswordManager", lambda: pm)
monkeypatch.setattr(cli_common, "PasswordManager", lambda: pm)
result = runner.invoke(app, ["entry", command] + cli_args)
assert result.exit_code == 0
assert stdout in result.stdout
+35 -2
View File
@@ -17,8 +17,8 @@ def _setup_pm(tmp_path: Path):
cfg = ConfigManager(vault, tmp_path)
backup = BackupManager(tmp_path, cfg)
pm = SimpleNamespace(
handle_export_database=lambda p: export_backup(
vault, backup, p, parent_seed=TEST_SEED
handle_export_database=lambda p, encrypt=True: export_backup(
vault, backup, p, parent_seed=TEST_SEED, encrypt=encrypt
),
handle_import_database=lambda p: import_backup(
vault, backup, p, parent_seed=TEST_SEED
@@ -91,3 +91,36 @@ def test_cli_import_round_trip(monkeypatch, tmp_path):
rc = main.main(["import", "--file", str(export_path)])
assert rc == 0
assert vault.load_index() == original
def test_cli_export_import_unencrypted(monkeypatch, tmp_path):
pm, vault = _setup_pm(tmp_path)
data = {
"schema_version": 4,
"entries": {
"0": {
"label": "example",
"type": "password",
"notes": "",
"custom_fields": [],
"origin": "",
"tags": [],
}
},
}
vault.save_index(data)
monkeypatch.setattr(main, "PasswordManager", lambda *a, **k: pm)
monkeypatch.setattr(main, "configure_logging", lambda: None)
monkeypatch.setattr(main, "initialize_app", lambda: None)
monkeypatch.setattr(main.signal, "signal", lambda *a, **k: None)
export_path = tmp_path / "out.json"
rc = main.main(["export", "--file", str(export_path), "--unencrypted"])
assert rc == 0
assert export_path.exists()
vault.save_index({"schema_version": 4, "entries": {}})
rc = main.main(["import", "--file", str(export_path)])
assert rc == 0
assert vault.load_index() == data
+4 -26
View File
@@ -1,11 +1,9 @@
import importlib
import shutil
from contextlib import redirect_stdout
from io import StringIO
from pathlib import Path
from types import SimpleNamespace
from tests.helpers import TEST_PASSWORD, TEST_SEED
from helpers import TEST_PASSWORD, TEST_SEED
import colorama
import constants
@@ -58,31 +56,11 @@ def test_cli_integration(monkeypatch, tmp_path):
monkeypatch.setattr(manager_module.PasswordManager, "add_new_fingerprint", auto_add)
monkeypatch.setattr("builtins.input", lambda *a, **k: "1")
buf = StringIO()
with redirect_stdout(buf):
try:
cli_module.app(["fingerprint", "add"])
except SystemExit as e:
assert e.code == 0
buf.truncate(0)
buf.seek(0)
cli_module.app(["fingerprint", "add"], standalone_mode=False)
with redirect_stdout(buf):
try:
cli_module.app(["entry", "add", "Example", "--length", "8"])
except SystemExit as e:
assert e.code == 0
buf.truncate(0)
buf.seek(0)
cli_module.app(["entry", "add", "Example", "--length", "8"], standalone_mode=False)
with redirect_stdout(buf):
try:
cli_module.app(["entry", "get", "Example"])
except SystemExit as e:
assert e.code == 0
lines = [line for line in buf.getvalue().splitlines() if line.strip()]
password = lines[-1]
assert len(password.strip()) >= 8
cli_module.app(["entry", "get", "Example"], standalone_mode=False)
fm = manager_module.FingerprintManager(constants.APP_DIR)
fp = fm.current_fingerprint
+3 -3
View File
@@ -2,7 +2,7 @@ from types import SimpleNamespace
from typer.testing import CliRunner
from seedpass.cli import app
from seedpass import cli
from seedpass.cli import common as cli_common
class DummyService:
@@ -37,8 +37,8 @@ def test_cli_relay_crud(monkeypatch):
def pm_factory(*a, **k):
return SimpleNamespace()
monkeypatch.setattr(cli, "PasswordManager", pm_factory)
monkeypatch.setattr(cli, "NostrService", lambda pm: DummyService(relays))
monkeypatch.setattr(cli_common, "PasswordManager", pm_factory)
monkeypatch.setattr(cli_common, "NostrService", lambda pm: DummyService(relays))
result = runner.invoke(app, ["nostr", "list-relays"])
assert "1: wss://a" in result.stdout
+1 -1
View File
@@ -60,7 +60,7 @@ def test_totp_command(monkeypatch, capsys):
monkeypatch.setattr(main, "initialize_app", lambda: None)
monkeypatch.setattr(main.signal, "signal", lambda *a, **k: None)
monkeypatch.setattr(
main, "copy_to_clipboard", lambda v, d: called.setdefault("val", v)
main, "copy_to_clipboard", lambda v, d: (called.setdefault("val", v), True)[1]
)
rc = main.main(["totp", "ex"])
assert rc == 0
+7 -7
View File
@@ -2,12 +2,12 @@ from types import SimpleNamespace
from typer.testing import CliRunner
from seedpass.cli import app
from seedpass import cli
from seedpass.cli import common as cli_common
runner = CliRunner()
def _make_pm(called, enabled=False):
def _make_pm(called, enabled=True):
cfg = SimpleNamespace(
get_offline_mode=lambda: enabled,
set_offline_mode=lambda v: called.setdefault("enabled", v),
@@ -23,17 +23,17 @@ def _make_pm(called, enabled=False):
def test_toggle_offline_updates(monkeypatch):
called = {}
pm = _make_pm(called)
monkeypatch.setattr(cli, "PasswordManager", lambda: pm)
result = runner.invoke(app, ["config", "toggle-offline"], input="y\n")
monkeypatch.setattr(cli_common, "PasswordManager", lambda: pm)
result = runner.invoke(app, ["config", "toggle-offline"], input="n\n")
assert result.exit_code == 0
assert called == {"enabled": True}
assert "Offline mode enabled." in result.stdout
assert called == {"enabled": False}
assert "Offline mode disabled." in result.stdout
def test_toggle_offline_keep(monkeypatch):
called = {}
pm = _make_pm(called, enabled=True)
monkeypatch.setattr(cli, "PasswordManager", lambda: pm)
monkeypatch.setattr(cli_common, "PasswordManager", lambda: pm)
result = runner.invoke(app, ["config", "toggle-offline"], input="\n")
assert result.exit_code == 0
assert called == {"enabled": True}
+3 -3
View File
@@ -3,7 +3,7 @@ from types import SimpleNamespace
from typer.testing import CliRunner
from seedpass.cli import app
from seedpass import cli
from seedpass.cli import common as cli_common
runner = CliRunner()
@@ -27,7 +27,7 @@ def _make_pm(called, enabled=False, delay=45):
def test_toggle_secret_mode_updates(monkeypatch):
called = {}
pm = _make_pm(called)
monkeypatch.setattr(cli, "PasswordManager", lambda: pm)
monkeypatch.setattr(cli_common, "PasswordManager", lambda: pm)
result = runner.invoke(app, ["config", "toggle-secret-mode"], input="y\n10\n")
assert result.exit_code == 0
assert called == {"enabled": True, "delay": 10}
@@ -37,7 +37,7 @@ def test_toggle_secret_mode_updates(monkeypatch):
def test_toggle_secret_mode_keep(monkeypatch):
called = {}
pm = _make_pm(called, enabled=True, delay=30)
monkeypatch.setattr(cli, "PasswordManager", lambda: pm)
monkeypatch.setattr(cli_common, "PasswordManager", lambda: pm)
result = runner.invoke(app, ["config", "toggle-secret-mode"], input="\n\n")
assert result.exit_code == 0
assert called == {"enabled": True, "delay": 30}
+2 -2
View File
@@ -3,7 +3,7 @@ from types import SimpleNamespace
from typer.testing import CliRunner
from seedpass.cli import app
from seedpass import cli
from seedpass.cli import common as cli_common
runner = CliRunner()
@@ -16,7 +16,7 @@ def test_vault_stats_command(monkeypatch):
pm = SimpleNamespace(
get_profile_stats=lambda: stats, select_fingerprint=lambda fp: None
)
monkeypatch.setattr(cli, "PasswordManager", lambda: pm)
monkeypatch.setattr(cli_common, "PasswordManager", lambda: pm)
result = runner.invoke(app, ["vault", "stats"])
assert result.exit_code == 0
out = result.stdout
+16 -1
View File
@@ -1,12 +1,15 @@
from pathlib import Path
import pyperclip
import threading
import shutil
import sys
sys.path.append(str(Path(__file__).resolve().parents[1]))
from utils.clipboard import copy_to_clipboard
import pytest
from utils.clipboard import ClipboardUnavailableError, copy_to_clipboard
def test_copy_to_clipboard_clears(monkeypatch):
@@ -66,3 +69,15 @@ def test_copy_to_clipboard_does_not_clear_if_changed(monkeypatch):
fake_copy("other")
callbacks["func"]()
assert clipboard["text"] == "other"
def test_copy_to_clipboard_missing_dependency(monkeypatch):
def fail_copy(*args, **kwargs):
raise pyperclip.PyperclipException("no copy")
monkeypatch.setattr(pyperclip, "copy", fail_copy)
monkeypatch.setattr(pyperclip, "paste", lambda: "")
monkeypatch.setattr(shutil, "which", lambda cmd: None)
with pytest.raises(ClipboardUnavailableError):
copy_to_clipboard("secret", 1)
+3 -1
View File
@@ -3,6 +3,7 @@ from pathlib import Path
from multiprocessing import Process, Queue
import pytest
from helpers import TEST_SEED, TEST_PASSWORD
from utils.fingerprint import generate_fingerprint
sys.path.append(str(Path(__file__).resolve().parents[1]))
@@ -51,7 +52,8 @@ def _backup(index_key: bytes, dir_path: Path, loops: int, out: Queue) -> None:
@pytest.mark.parametrize("_", range(3))
def test_concurrency_stress(tmp_path: Path, loops: int, _):
index_key = derive_index_key(TEST_SEED)
seed_key = derive_key_from_password(TEST_PASSWORD)
fp = generate_fingerprint(TEST_SEED)
seed_key = derive_key_from_password(TEST_PASSWORD, fp)
EncryptionManager(seed_key, tmp_path).encrypt_parent_seed(TEST_SEED)
enc = EncryptionManager(index_key, tmp_path)
Vault(enc, tmp_path).save_index({"counter": 0})
+1 -1
View File
@@ -23,7 +23,7 @@ def test_config_defaults_and_round_trip():
assert cfg["pin_hash"] == ""
assert cfg["password_hash"] == ""
assert cfg["additional_backup_path"] == ""
assert cfg["quick_unlock"] is False
assert cfg["quick_unlock_enabled"] is False
assert cfg["kdf_iterations"] == 50_000
cfg_mgr.set_pin("1234")
+529
View File
@@ -0,0 +1,529 @@
import pytest
from unittest.mock import MagicMock, patch, call
from pathlib import Path
from threading import Lock
import json
from dataclasses import dataclass
from seedpass.core.api import (
VaultService,
ProfileService,
SyncService,
EntryService,
ConfigService,
UtilityService,
NostrService,
VaultExportRequest,
VaultExportResponse,
VaultImportRequest,
ChangePasswordRequest,
UnlockRequest,
UnlockResponse,
BackupParentSeedRequest,
ProfileSwitchRequest,
ProfileRemoveRequest,
PasswordPolicyOptions,
GeneratePasswordRequest,
GeneratePasswordResponse,
AddPasswordEntryRequest,
)
from seedpass.core.manager import PasswordManager
from seedpass.core.entry_types import EntryType
@dataclass
class MockPolicy:
include_special_chars: bool = False
min_uppercase: int = 0
@pytest.fixture
def mock_manager():
"""Create a mock PasswordManager with all sub-components mocked."""
manager = MagicMock(spec=PasswordManager)
# Mock sub-managers
manager.entry_manager = MagicMock()
manager.config_manager = MagicMock()
manager.fingerprint_manager = MagicMock()
manager.encryption_manager = MagicMock()
manager.vault = MagicMock()
manager.password_generator = MagicMock()
manager.nostr_client = MagicMock()
manager.state_manager = MagicMock()
# Mock specific attributes
manager.KEY_TOTP_DET = b"mock_totp_key"
manager.parent_seed = "mock_parent_seed"
manager.current_fingerprint = "fp1"
return manager
@pytest.fixture
def mock_bus():
"""Mock the pubsub bus."""
with patch("seedpass.core.api.bus") as mock:
yield mock
class TestVaultService:
@pytest.fixture
def service(self, mock_manager):
return VaultService(mock_manager)
def test_export_vault(self, service, mock_manager):
req = VaultExportRequest(path=Path("/tmp/export.json"))
resp = service.export_vault(req)
mock_manager.handle_export_database.assert_called_once_with(req.path)
assert resp.path == req.path
def test_import_vault(self, service, mock_manager):
req = VaultImportRequest(path=Path("/tmp/import.json"))
service.import_vault(req)
mock_manager.handle_import_database.assert_called_once_with(req.path)
mock_manager.sync_vault.assert_called_once()
def test_export_profile(self, service, mock_manager):
mock_index = {"entries": {}}
mock_manager.vault.load_index.return_value = mock_index
mock_manager.vault.encryption_manager.encrypt_data.return_value = b"encrypted"
result = service.export_profile()
mock_manager.vault.load_index.assert_called_once()
expected_payload = json.dumps(
mock_index, sort_keys=True, separators=(",", ":")
).encode("utf-8")
mock_manager.vault.encryption_manager.encrypt_data.assert_called_once_with(
expected_payload
)
assert result == b"encrypted"
def test_import_profile(self, service, mock_manager):
data = b"encrypted_data"
decrypted_json = json.dumps({"entries": {}})
mock_manager.vault.encryption_manager.decrypt_data.return_value = (
decrypted_json.encode("utf-8")
)
service.import_profile(data)
mock_manager.vault.encryption_manager.decrypt_data.assert_called_once_with(
data, context="profile"
)
mock_manager.vault.save_index.assert_called_once_with(
json.loads(decrypted_json)
)
mock_manager.sync_vault.assert_called_once()
def test_change_password(self, service, mock_manager):
req = ChangePasswordRequest(old_password="old", new_password="new")
service.change_password(req)
mock_manager.change_password.assert_called_once_with("old", "new")
def test_unlock(self, service, mock_manager):
mock_manager.unlock_vault.return_value = 0.5
req = UnlockRequest(password="secret")
resp = service.unlock(req)
mock_manager.unlock_vault.assert_called_once_with("secret")
assert resp.duration == 0.5
def test_lock(self, service, mock_manager):
service.lock()
mock_manager.lock_vault.assert_called_once()
def test_backup_parent_seed(self, service, mock_manager):
req = BackupParentSeedRequest(path=Path("/tmp/seed.enc"), password="pw")
service.backup_parent_seed(req)
mock_manager.handle_backup_reveal_parent_seed.assert_called_once_with(
req.path, password=req.password
)
def test_stats(self, service, mock_manager):
expected_stats = {"entries": 10}
mock_manager.get_profile_stats.return_value = expected_stats
stats = service.stats()
mock_manager.get_profile_stats.assert_called_once()
assert stats == expected_stats
class TestProfileService:
@pytest.fixture
def service(self, mock_manager):
return ProfileService(mock_manager)
def test_list_profiles(self, service, mock_manager):
mock_manager.fingerprint_manager.list_fingerprints.return_value = ["fp1", "fp2"]
profiles = service.list_profiles()
mock_manager.fingerprint_manager.list_fingerprints.assert_called_once()
assert profiles == ["fp1", "fp2"]
def test_add_profile(self, service, mock_manager):
mock_manager.fingerprint_manager.current_fingerprint = "new_fp"
result = service.add_profile()
mock_manager.add_new_fingerprint.assert_called_once()
assert result == "new_fp"
def test_remove_profile(self, service, mock_manager):
req = ProfileRemoveRequest(fingerprint="fp1")
service.remove_profile(req)
mock_manager.fingerprint_manager.remove_fingerprint.assert_called_once_with(
"fp1"
)
def test_switch_profile(self, service, mock_manager):
req = ProfileSwitchRequest(fingerprint="fp2", password="pw")
service.switch_profile(req)
mock_manager.select_fingerprint.assert_called_once_with("fp2", password="pw")
class TestSyncService:
@pytest.fixture
def service(self, mock_manager):
return SyncService(mock_manager)
def test_sync(self, service, mock_manager, mock_bus):
mock_result = {"manifest_id": "m1"}
mock_manager.sync_vault.return_value = mock_result
result = service.sync()
mock_bus.publish.assert_has_calls(
[call("sync_started"), call("sync_finished", mock_result)]
)
mock_manager.sync_vault.assert_called_once()
assert result.manifest_id == "m1"
def test_sync_none(self, service, mock_manager, mock_bus):
mock_manager.sync_vault.return_value = None
result = service.sync()
mock_bus.publish.assert_has_calls(
[call("sync_started"), call("sync_finished", None)]
)
assert result is None
def test_start_background_sync(self, service, mock_manager):
service.start_background_sync()
mock_manager.start_background_sync.assert_called_once()
def test_start_background_vault_sync(self, service, mock_manager):
service.start_background_vault_sync("summary")
mock_manager.start_background_vault_sync.assert_called_once_with("summary")
class TestEntryService:
@pytest.fixture
def service(self, mock_manager):
return EntryService(mock_manager)
def test_list_entries(self, service, mock_manager):
mock_manager.entry_manager.list_entries.return_value = []
entries = service.list_entries()
mock_manager.entry_manager.list_entries.assert_called_once_with(
sort_by="index", filter_kinds=None, include_archived=False
)
assert entries == []
def test_search_entries(self, service, mock_manager):
mock_manager.entry_manager.search_entries.return_value = []
results = service.search_entries("query")
mock_manager.entry_manager.search_entries.assert_called_once_with(
"query", kinds=None
)
assert results == []
def test_retrieve_entry(self, service, mock_manager):
mock_entry = {"id": 1, "label": "test"}
mock_manager.entry_manager.retrieve_entry.return_value = mock_entry
entry = service.retrieve_entry(1)
mock_manager.entry_manager.retrieve_entry.assert_called_once_with(1)
assert entry == mock_entry
def test_generate_password_default(self, service, mock_manager):
# Case where _generate_password_for_entry is not available
del mock_manager._generate_password_for_entry
mock_manager.password_generator.generate_password.return_value = "pass123"
mock_manager.entry_manager.retrieve_entry.return_value = {}
password = service.generate_password(10, 1)
mock_manager.password_generator.generate_password.assert_called_once_with(10, 1)
assert password == "pass123"
def test_generate_password_custom(self, service, mock_manager):
# Case where _generate_password_for_entry is available
mock_manager._generate_password_for_entry = MagicMock(return_value="custom123")
mock_manager.entry_manager.retrieve_entry.return_value = {"policy": {}}
password = service.generate_password(10, 1)
mock_manager._generate_password_for_entry.assert_called_once()
assert password == "custom123"
def test_get_totp_code(self, service, mock_manager):
mock_manager.KEY_TOTP_DET = b"key"
mock_manager.entry_manager.get_totp_code.return_value = "123456"
code = service.get_totp_code(1)
mock_manager.entry_manager.get_totp_code.assert_called_once_with(1, b"key")
assert code == "123456"
def test_add_entry(self, service, mock_manager):
mock_manager.entry_manager.add_entry.return_value = 1
idx = service.add_entry("label", 12, "user", "url")
mock_manager.entry_manager.add_entry.assert_called_once()
mock_manager.start_background_vault_sync.assert_called_once()
assert idx == 1
def test_add_totp(self, service, mock_manager):
mock_manager.entry_manager.add_totp.return_value = "otpauth://..."
uri = service.add_totp("label")
mock_manager.entry_manager.add_totp.assert_called_once()
mock_manager.start_background_vault_sync.assert_called_once()
assert uri == "otpauth://..."
def test_add_ssh_key(self, service, mock_manager):
mock_manager.entry_manager.add_ssh_key.return_value = 2
idx = service.add_ssh_key("label")
mock_manager.entry_manager.add_ssh_key.assert_called_once()
mock_manager.start_background_vault_sync.assert_called_once()
assert idx == 2
def test_add_pgp_key(self, service, mock_manager):
mock_manager.entry_manager.add_pgp_key.return_value = 3
idx = service.add_pgp_key("label")
mock_manager.entry_manager.add_pgp_key.assert_called_once()
mock_manager.start_background_vault_sync.assert_called_once()
assert idx == 3
def test_add_nostr_key(self, service, mock_manager):
mock_manager.entry_manager.add_nostr_key.return_value = 4
idx = service.add_nostr_key("label")
mock_manager.entry_manager.add_nostr_key.assert_called_once()
mock_manager.start_background_vault_sync.assert_called_once()
assert idx == 4
def test_add_seed(self, service, mock_manager):
mock_manager.entry_manager.add_seed.return_value = 5
idx = service.add_seed("label")
mock_manager.entry_manager.add_seed.assert_called_once()
mock_manager.start_background_vault_sync.assert_called_once()
assert idx == 5
def test_add_key_value(self, service, mock_manager):
mock_manager.entry_manager.add_key_value.return_value = 6
idx = service.add_key_value("label", "k", "v")
mock_manager.entry_manager.add_key_value.assert_called_once()
mock_manager.start_background_vault_sync.assert_called_once()
assert idx == 6
def test_add_managed_account(self, service, mock_manager):
mock_manager.entry_manager.add_managed_account.return_value = 7
idx = service.add_managed_account("label")
mock_manager.entry_manager.add_managed_account.assert_called_once()
mock_manager.start_background_vault_sync.assert_called_once()
assert idx == 7
def test_modify_entry(self, service, mock_manager):
service.modify_entry(1, label="new_label")
mock_manager.entry_manager.modify_entry.assert_called_once_with(
1,
username=None,
url=None,
notes=None,
label="new_label",
period=None,
digits=None,
key=None,
value=None,
)
mock_manager.start_background_vault_sync.assert_called_once()
def test_archive_entry(self, service, mock_manager):
service.archive_entry(1)
mock_manager.entry_manager.archive_entry.assert_called_once_with(1)
mock_manager.start_background_vault_sync.assert_called_once()
def test_restore_entry(self, service, mock_manager):
service.restore_entry(1)
mock_manager.entry_manager.restore_entry.assert_called_once_with(1)
mock_manager.start_background_vault_sync.assert_called_once()
def test_export_totp_entries(self, service, mock_manager):
mock_manager.entry_manager.export_totp_entries.return_value = {}
result = service.export_totp_entries()
mock_manager.entry_manager.export_totp_entries.assert_called_once()
assert result == {}
def test_display_totp_codes(self, service, mock_manager):
service.display_totp_codes()
mock_manager.handle_display_totp_codes.assert_called_once()
class TestConfigService:
@pytest.fixture
def service(self, mock_manager):
return ConfigService(mock_manager)
def test_get(self, service, mock_manager):
mock_manager.config_manager.load_config.return_value = {"key": "value"}
val = service.get("key")
mock_manager.config_manager.load_config.assert_called_once_with(
require_pin=False
)
assert val == "value"
def test_set_simple(self, service, mock_manager):
service.set("inactivity_timeout", "10")
mock_manager.config_manager.set_inactivity_timeout.assert_called_once_with(10.0)
def test_set_bool(self, service, mock_manager):
service.set("secret_mode_enabled", "true")
mock_manager.config_manager.set_secret_mode_enabled.assert_called_once_with(
True
)
def test_set_relays(self, service, mock_manager):
# relays setter logic: ("set_relays", lambda v: (v, {"require_pin": False})),
# but the lambda returns a tuple (arg, kwargs)
# and then getattr(cfg, method_name)(arg, **kwargs) is called.
# Wait, the lambda returns (v, {"require_pin": False})
# So arg is v, kwargs is {"require_pin": False}
service.set("relays", ["r1"])
mock_manager.config_manager.set_relays.assert_called_once_with(
["r1"], require_pin=False
)
def test_set_invalid_key(self, service, mock_manager):
with pytest.raises(KeyError):
service.set("invalid_key", "val")
def test_get_secret_mode_enabled(self, service, mock_manager):
mock_manager.config_manager.get_secret_mode_enabled.return_value = True
assert service.get_secret_mode_enabled() is True
def test_get_clipboard_clear_delay(self, service, mock_manager):
mock_manager.config_manager.get_clipboard_clear_delay.return_value = 45
assert service.get_clipboard_clear_delay() == 45
def test_set_secret_mode(self, service, mock_manager):
service.set_secret_mode(True, 30)
mock_manager.config_manager.set_secret_mode_enabled.assert_called_once_with(
True
)
mock_manager.config_manager.set_clipboard_clear_delay.assert_called_once_with(
30
)
assert mock_manager.secret_mode_enabled is True
assert mock_manager.clipboard_clear_delay == 30
def test_get_offline_mode(self, service, mock_manager):
mock_manager.config_manager.get_offline_mode.return_value = False
assert service.get_offline_mode() is False
def test_set_offline_mode(self, service, mock_manager):
service.set_offline_mode(True)
mock_manager.config_manager.set_offline_mode.assert_called_once_with(True)
assert mock_manager.offline_mode is True
class TestUtilityService:
@pytest.fixture
def service(self, mock_manager):
return UtilityService(mock_manager)
def test_generate_password(self, service, mock_manager):
mock_manager.password_generator.generate_password.return_value = "pw"
# Mock policy with a real dataclass
base_policy = MockPolicy(include_special_chars=False, min_uppercase=0)
mock_manager.password_generator.policy = base_policy
result = service.generate_password(
10, include_special_chars=True, min_uppercase=5
)
mock_manager.password_generator.generate_password.assert_called_once_with(10)
assert result == "pw"
# Verify policy was restored
assert mock_manager.password_generator.policy == base_policy
def test_verify_checksum(self, service, mock_manager):
service.verify_checksum()
mock_manager.handle_verify_checksum.assert_called_once()
def test_update_checksum(self, service, mock_manager):
service.update_checksum()
mock_manager.handle_update_script_checksum.assert_called_once()
class TestNostrService:
@pytest.fixture
def service(self, mock_manager):
return NostrService(mock_manager)
def test_get_pubkey(self, service, mock_manager):
mock_manager.nostr_client.key_manager.get_npub.return_value = "npub1..."
assert service.get_pubkey() == "npub1..."
def test_list_relays(self, service, mock_manager):
mock_manager.state_manager.list_relays.return_value = ["r1"]
assert service.list_relays() == ["r1"]
def test_add_relay(self, service, mock_manager):
mock_manager.state_manager.list_relays.return_value = ["r1", "r2"]
service.add_relay("r2")
mock_manager.state_manager.add_relay.assert_called_once_with("r2")
assert mock_manager.nostr_client.relays == ["r1", "r2"]
def test_remove_relay(self, service, mock_manager):
mock_manager.state_manager.list_relays.return_value = []
service.remove_relay(0)
mock_manager.state_manager.remove_relay.assert_called_once_with(0)
assert mock_manager.nostr_client.relays == []
+20 -43
View File
@@ -1,53 +1,30 @@
import sys
from pathlib import Path
from tempfile import TemporaryDirectory
from types import SimpleNamespace
from helpers import create_vault, TEST_SEED, TEST_PASSWORD
sys.path.append(str(Path(__file__).resolve().parents[1]))
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
def test_retrieve_entry_shows_custom_fields(monkeypatch, capsys, password_manager):
pm = password_manager
pm.password_generator = SimpleNamespace(generate_password=lambda l, i: "pw")
pm.nostr_client = SimpleNamespace()
def test_retrieve_entry_shows_custom_fields(monkeypatch, capsys):
with TemporaryDirectory() as tmpdir:
tmp_path = Path(tmpdir)
vault, enc_mgr = create_vault(tmp_path, TEST_SEED, TEST_PASSWORD)
cfg_mgr = ConfigManager(vault, tmp_path)
backup_mgr = BackupManager(tmp_path, cfg_mgr)
entry_mgr = EntryManager(vault, backup_mgr)
pm.entry_manager.add_entry(
"example",
8,
custom_fields=[
{"label": "visible", "value": "shown", "is_hidden": False},
{"label": "token", "value": "secret", "is_hidden": True},
],
)
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 = SimpleNamespace(generate_password=lambda l, i: "pw")
pm.parent_seed = TEST_SEED
pm.nostr_client = SimpleNamespace()
pm.fingerprint_dir = tmp_path
pm.secret_mode_enabled = False
inputs = iter(["0", "y", "", ""])
monkeypatch.setattr("builtins.input", lambda *a, **k: next(inputs))
entry_mgr.add_entry(
"example",
8,
custom_fields=[
{"label": "visible", "value": "shown", "is_hidden": False},
{"label": "token", "value": "secret", "is_hidden": True},
],
)
inputs = iter(["0", "y", "", ""])
monkeypatch.setattr("builtins.input", lambda *a, **k: next(inputs))
pm.handle_retrieve_entry()
out = capsys.readouterr().out
assert "Additional Fields:" in out
assert "visible: shown" in out
assert "token" in out
assert "secret" in out
pm.handle_retrieve_entry()
out = capsys.readouterr().out
assert "Additional Fields:" in out
assert "visible: shown" in out
assert "token" in out
assert "secret" in out
+58
View File
@@ -0,0 +1,58 @@
import base64
import hashlib
import unicodedata
import pytest
from cryptography.fernet import InvalidToken
from helpers import TEST_PASSWORD, TEST_SEED
from seedpass.core.encryption import (
EncryptionManager,
LegacyFormatRequiresMigrationError,
)
from utils.key_derivation import derive_index_key
def test_wrong_password_message(tmp_path):
key = derive_index_key(TEST_SEED)
mgr = EncryptionManager(key, tmp_path)
payload = mgr.encrypt_data(b"secret")
wrong_key = bytearray(key)
wrong_key[0] ^= 1
wrong_mgr = EncryptionManager(bytes(wrong_key), tmp_path)
with pytest.raises(InvalidToken, match="invalid key or corrupt file") as exc:
wrong_mgr.decrypt_data(payload, context="index")
assert "index" in str(exc.value)
def test_legacy_file_requires_migration_message(tmp_path, monkeypatch):
def _fast_legacy_key(password: str, iterations: int = 100_000) -> bytes:
normalized = unicodedata.normalize("NFKD", password).strip().encode("utf-8")
key = hashlib.pbkdf2_hmac("sha256", normalized, b"", 1, dklen=32)
return base64.urlsafe_b64encode(key)
monkeypatch.setattr(
"seedpass.core.encryption._derive_legacy_key_from_password", _fast_legacy_key
)
legacy_key = _fast_legacy_key(TEST_PASSWORD)
legacy_mgr = EncryptionManager(legacy_key, tmp_path)
token = legacy_mgr.fernet.encrypt(b"secret")
new_mgr = EncryptionManager(derive_index_key(TEST_SEED), tmp_path)
with pytest.raises(LegacyFormatRequiresMigrationError, match="index") as exc:
new_mgr.decrypt_data(token, context="index")
assert "index" in str(exc.value)
def test_corrupted_data_message(tmp_path):
key = derive_index_key(TEST_SEED)
mgr = EncryptionManager(key, tmp_path)
payload = bytearray(mgr.encrypt_data(b"secret"))
payload[-1] ^= 0xFF
with pytest.raises(InvalidToken, match="invalid key or corrupt file") as exc:
mgr.decrypt_data(bytes(payload), context="index")
assert "index" in str(exc.value)
@@ -0,0 +1,53 @@
import pytest
from pathlib import Path
from tempfile import TemporaryDirectory
from seedpass.core.manager import PasswordManager
from utils.fingerprint_manager import FingerprintManager
from utils.fingerprint import generate_fingerprint
VALID_SEED = "abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon about"
def _setup_pm(tmp_path, monkeypatch):
pm = PasswordManager.__new__(PasswordManager)
pm.fingerprint_manager = FingerprintManager(tmp_path)
pm.config_manager = type("Cfg", (), {"get_kdf_iterations": lambda self: 1})()
monkeypatch.setattr("seedpass.core.manager.prompt_for_password", lambda: "pw")
monkeypatch.setattr("seedpass.core.manager.derive_index_key", lambda seed: b"idx")
monkeypatch.setattr(
"seedpass.core.manager.derive_key_from_password", lambda *a, **k: b"k"
)
class DummyEnc:
def __init__(self, *a, **k):
pass
def encrypt_parent_seed(self, seed):
pass
monkeypatch.setattr("seedpass.core.manager.EncryptionManager", DummyEnc)
monkeypatch.setattr("seedpass.core.manager.Vault", lambda *a, **k: object())
monkeypatch.setattr(
"seedpass.core.manager.ConfigManager", lambda **k: pm.config_manager
)
monkeypatch.setattr(pm, "initialize_bip85", lambda: None)
monkeypatch.setattr(pm, "initialize_managers", lambda: None)
monkeypatch.setattr(pm, "start_background_sync", lambda: None)
monkeypatch.setattr(pm, "store_hashed_password", lambda pw: None)
monkeypatch.setattr("seedpass.core.manager.confirm_action", lambda *_: False)
return pm
def test_duplicate_seed_profile_creation(monkeypatch):
with TemporaryDirectory() as tmpdir:
tmp_path = Path(tmpdir)
pm = _setup_pm(tmp_path, monkeypatch)
fp1 = pm._finalize_existing_seed(VALID_SEED, password="pw")
assert fp1 == generate_fingerprint(VALID_SEED)
assert pm.fingerprint_manager.list_fingerprints() == [fp1]
fp2 = pm._finalize_existing_seed(VALID_SEED, password="pw")
assert fp2 is None
assert pm.fingerprint_manager.list_fingerprints() == [fp1]
+10 -33
View File
@@ -1,49 +1,26 @@
import sys
from pathlib import Path
from tempfile import TemporaryDirectory
from types import SimpleNamespace
from helpers import create_vault, TEST_SEED, TEST_PASSWORD
sys.path.append(str(Path(__file__).resolve().parents[1]))
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
class FakePasswordGenerator:
def generate_password(self, length: int, index: int) -> str: # noqa: D401
return "pw"
def test_edit_tags_from_retrieve(monkeypatch):
with TemporaryDirectory() as tmpdir:
tmp_path = Path(tmpdir)
vault, enc_mgr = create_vault(tmp_path, TEST_SEED, TEST_PASSWORD)
cfg_mgr = ConfigManager(vault, tmp_path)
backup_mgr = BackupManager(tmp_path, cfg_mgr)
entry_mgr = EntryManager(vault, backup_mgr)
def test_edit_tags_from_retrieve(monkeypatch, password_manager):
pm = password_manager
pm.password_generator = FakePasswordGenerator()
pm.nostr_client = SimpleNamespace()
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 = SimpleNamespace()
pm.fingerprint_dir = tmp_path
pm.secret_mode_enabled = False
index = pm.entry_manager.add_entry("example.com", 8, tags=["old"])
index = entry_mgr.add_entry("example.com", 8, tags=["old"])
inputs = iter([str(index), "t", "newtag", ""])
monkeypatch.setattr("builtins.input", lambda *a, **k: next(inputs))
inputs = iter([str(index), "t", "newtag", ""])
monkeypatch.setattr("builtins.input", lambda *a, **k: next(inputs))
pm.handle_retrieve_entry()
pm.handle_retrieve_entry()
entry = entry_mgr.retrieve_entry(index)
assert entry.get("tags", []) == ["newtag"]
entry = pm.entry_manager.retrieve_entry(index)
assert entry.get("tags", []) == ["newtag"]
+23 -1
View File
@@ -1,5 +1,6 @@
import re
import sys
import hashlib
from pathlib import Path
from tempfile import TemporaryDirectory
@@ -23,7 +24,7 @@ def test_encryption_checksum_workflow():
manager.update_checksum()
enc_file = tmp_path / "seedpass_entries_db.json.enc"
chk_file = tmp_path / "seedpass_entries_db.json_checksum.txt"
chk_file = tmp_path / "seedpass_entries_db_checksum.txt"
checksum = chk_file.read_text().strip()
assert re.fullmatch(r"[0-9a-f]{64}", checksum)
@@ -33,3 +34,24 @@ def test_encryption_checksum_workflow():
manager.update_checksum()
assert verify_and_update_checksum(str(enc_file), str(chk_file))
def test_update_checksum_removes_legacy(tmp_path):
key = base64.urlsafe_b64encode(os.urandom(32))
manager = EncryptionManager(key, tmp_path)
manager.save_json_data({"value": 1})
legacy = tmp_path / "seedpass_entries_db.json_checksum.txt"
legacy.write_text("legacy")
manager.update_checksum()
enc_file = tmp_path / "seedpass_entries_db.json.enc"
new_chk = tmp_path / "seedpass_entries_db_checksum.txt"
assert new_chk.exists()
assert not legacy.exists()
expected = hashlib.sha256(enc_file.read_bytes()).hexdigest()
assert new_chk.read_text() == expected
+13
View File
@@ -5,6 +5,7 @@ from tempfile import TemporaryDirectory
import os
import base64
import pytest
sys.path.append(str(Path(__file__).resolve().parents[1]))
@@ -40,3 +41,15 @@ def test_encrypt_and_decrypt_file_binary_round_trip():
file_path = Path(tmpdir) / rel
raw = file_path.read_bytes()
assert raw != payload
def test_encrypt_file_rejects_traversal():
with TemporaryDirectory() as tmpdir:
key = base64.urlsafe_b64encode(os.urandom(32))
manager = EncryptionManager(key, Path(tmpdir))
with pytest.raises(ValueError):
manager.encrypt_and_save_file(b"data", Path("../evil.enc"))
with pytest.raises(ValueError):
manager.encrypt_and_save_file(b"data", Path("/abs.enc"))
+37
View File
@@ -0,0 +1,37 @@
import pytest
from pathlib import Path
from cryptography.fernet import Fernet
from hypothesis import given, strategies as st, settings, HealthCheck
from seedpass.core.encryption import EncryptionManager
@given(blob=st.binary())
@settings(
deadline=None,
max_examples=25,
suppress_health_check=[HealthCheck.function_scoped_fixture],
)
def test_encrypt_decrypt_roundtrip(blob: bytes, tmp_path: Path) -> None:
"""Ensure arbitrary data round-trips through EncryptionManager."""
key = Fernet.generate_key()
mgr = EncryptionManager(key, tmp_path)
encrypted = mgr.encrypt_data(blob)
assert mgr.decrypt_data(encrypted) == blob
@given(blob=st.binary())
@settings(
deadline=None,
max_examples=25,
suppress_health_check=[HealthCheck.function_scoped_fixture],
)
def test_corrupted_ciphertext_fails(blob: bytes, tmp_path: Path) -> None:
"""Corrupted ciphertext should not decrypt successfully."""
key = Fernet.generate_key()
mgr = EncryptionManager(key, tmp_path)
encrypted = bytearray(mgr.encrypt_data(blob))
if encrypted:
encrypted[0] ^= 0xFF
with pytest.raises(Exception):
mgr.decrypt_data(bytes(encrypted))
@@ -0,0 +1,30 @@
import sys
from pathlib import Path
import pytest
from cryptography.fernet import Fernet
sys.path.append(str(Path(__file__).resolve().parents[1]))
from seedpass.core.encryption import EncryptionManager
def make_manager(tmp_path):
key = Fernet.generate_key()
return EncryptionManager(key, tmp_path)
def test_validate_seed_valid_mnemonic(tmp_path):
manager = make_manager(tmp_path)
valid = "abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon about"
is_valid, error = manager.validate_seed(valid)
assert is_valid is True
assert error is None
def test_validate_seed_invalid_mnemonic(tmp_path):
manager = make_manager(tmp_path)
invalid = "abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon"
is_valid, error = manager.validate_seed(invalid)
assert is_valid is False
assert error == "Invalid seed phrase."
+2 -2
View File
@@ -21,8 +21,8 @@ class DummyEnc:
class DummyBIP85:
def derive_entropy(self, index: int, bytes_len: int, app_no: int = 32) -> bytes:
return bytes((index + i) % 256 for i in range(bytes_len))
def derive_entropy(self, index: int, entropy_bytes: int, app_no: int = 32) -> bytes:
return bytes((index + i) % 256 for i in range(entropy_bytes))
def make_manager(tmp_path: Path) -> PasswordManager:
-43
View File
@@ -1,43 +0,0 @@
import logging
import sys
from pathlib import Path
sys.path.append(str(Path(__file__).resolve().parents[1]))
from nostr import event_handler
class SimpleEvent:
def __init__(self, id: str, created_at: int, content: str) -> None:
self.id = id
self.created_at = created_at
self.content = content
def test_handle_new_event_logs(caplog):
handler = event_handler.EventHandler()
evt = SimpleEvent("1", 0, "hello")
caplog.set_level(logging.INFO, logger=event_handler.logger.name)
handler.handle_new_event(evt)
assert (
"[New Event] ID: 1 | Created At: 1970-01-01 00:00:00 | Content: hello"
in caplog.text
)
def test_handle_new_event_error(monkeypatch, caplog):
handler = event_handler.EventHandler()
evt = SimpleEvent("2", 0, "boom")
def raise_info(*args, **kwargs):
raise RuntimeError("fail")
monkeypatch.setattr(event_handler.logger, "info", raise_info)
caplog.set_level(logging.ERROR, logger=event_handler.logger.name)
handler.handle_new_event(evt)
assert "Error handling new event: fail" in caplog.text
+1 -1
View File
@@ -2,7 +2,7 @@ import asyncio
from pathlib import Path
from tempfile import TemporaryDirectory
from helpers import create_vault, dummy_nostr_client
from helpers import create_vault
from seedpass.core.entry_management import EntryManager
from seedpass.core.backup import BackupManager
+1 -1
View File
@@ -2,7 +2,7 @@ import asyncio
from pathlib import Path
from tempfile import TemporaryDirectory
from helpers import create_vault, dummy_nostr_client
from helpers import create_vault
from seedpass.core.entry_management import EntryManager
from seedpass.core.backup import BackupManager
+21 -4
View File
@@ -3,12 +3,17 @@ from pathlib import Path
from hypothesis import given, strategies as st, settings, HealthCheck
from mnemonic import Mnemonic
import hashlib
import base64
import os
from utils.key_derivation import (
derive_key_from_password,
derive_key_from_password_argon2,
derive_index_key,
KdfConfig,
)
from utils.fingerprint import generate_fingerprint
from seedpass.core.encryption import EncryptionManager
cfg_values = st.one_of(
@@ -32,17 +37,29 @@ cfg_values = st.one_of(
def test_fuzz_key_round_trip(password, seed_bytes, config, mode, tmp_path: Path):
"""Ensure EncryptionManager round-trips arbitrary data."""
seed_phrase = Mnemonic("english").to_mnemonic(seed_bytes)
fp = generate_fingerprint(seed_phrase)
if mode == "argon2":
key = derive_key_from_password_argon2(
password, time_cost=1, memory_cost=8, parallelism=1
cfg = KdfConfig(
params={"time_cost": 1, "memory_cost": 8, "parallelism": 1},
salt_b64=base64.b64encode(
hashlib.sha256(fp.encode()).digest()[:16]
).decode(),
)
key = derive_key_from_password_argon2(password, cfg)
else:
key = derive_key_from_password(password, iterations=1)
key = derive_key_from_password(password, fp, iterations=1)
cfg = KdfConfig(
name="pbkdf2",
params={"iterations": 1},
salt_b64=base64.b64encode(
hashlib.sha256(fp.encode()).digest()[:16]
).decode(),
)
enc_mgr = EncryptionManager(key, tmp_path)
# Parent seed round trip
enc_mgr.encrypt_parent_seed(seed_phrase)
enc_mgr.encrypt_parent_seed(seed_phrase, kdf=cfg)
assert enc_mgr.decrypt_parent_seed() == seed_phrase
# JSON data round trip
Loaded 100 of 462 files, more files were not shown because too many files have changed in this diff. Show more