mirror of
https://github.com/PR0M3TH3AN/SeedPass.git
synced 2026-11-03 03:12:07 +00:00
Merge branch 'beta' into main (safe conflict resolution)
This commit is contained in:
commit
37d0dffdc3
462 files changed
+68862
-6529
No files matched your search
+2
-6
@@ -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)
|
||||
|
||||
|
||||
# -----------------------------------
|
||||
|
||||
@@ -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
@@ -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
@@ -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):
|
||||
|
||||
@@ -25,3 +25,4 @@ class Manifest:
|
||||
algo: str
|
||||
chunks: List[ChunkMeta]
|
||||
delta_since: Optional[int] = None
|
||||
nonce: Optional[str] = None
|
||||
+29
-617
@@ -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",
|
||||
]
|
||||
@@ -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
|
||||
|
||||
@@ -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)
|
||||
@@ -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
@@ -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.
|
||||
|
||||
@@ -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)
|
||||
@@ -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)
|
||||
@@ -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
|
||||
@@ -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
@@ -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
|
||||
@@ -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
|
||||
@@ -3,4 +3,3 @@ selected_directories:
|
||||
- utils/
|
||||
- nostr/
|
||||
- local_bip85/
|
||||
- password_manager/
|
||||
@@ -0,0 +1,3 @@
|
||||
"""SeedPass package initialization."""
|
||||
|
||||
# Optionally re-export selected symbols here.
|
||||
+350
-216
@@ -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"}
|
||||
@@ -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()
|
||||
@@ -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()
|
||||
@@ -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))
|
||||
@@ -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}")
|
||||
@@ -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)
|
||||
@@ -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}.")
|
||||
@@ -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))
|
||||
@@ -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)
|
||||
)
|
||||
@@ -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")
|
||||
@@ -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()
|
||||
@@ -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)
|
||||
)
|
||||
@@ -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:
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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
@@ -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:
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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()
|
||||
@@ -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]
|
||||
@@ -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"]
|
||||
+877
-734
File diff suppressed because it is too large.
Load diff
@@ -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"))
|
||||
@@ -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))
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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
|
||||
@@ -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:
|
||||
|
||||
@@ -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
@@ -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()
|
||||
)
|
||||
@@ -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,4 +1,4 @@
|
||||
from .app import main
|
||||
from . import main
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
@@ -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)
|
||||
|
||||
|
||||
@@ -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
@@ -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"
|
||||
@@ -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
@@ -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
@@ -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,
|
||||
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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)
|
||||
@@ -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
|
||||
@@ -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."
|
||||
@@ -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
|
||||
@@ -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)
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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"
|
||||
@@ -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'"
|
||||
@@ -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 == {}
|
||||
@@ -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
|
||||
@@ -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"
|
||||
@@ -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
|
||||
@@ -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])
|
||||
|
||||
|
||||
@@ -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"])
|
||||
|
||||
@@ -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)
|
||||
)
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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,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}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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,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})
|
||||
|
||||
@@ -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")
|
||||
|
||||
@@ -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 == []
|
||||
@@ -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
|
||||
@@ -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]
|
||||
@@ -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"]
|
||||
@@ -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
|
||||
@@ -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"))
|
||||
@@ -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."
|
||||
@@ -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:
|
||||
|
||||
@@ -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
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
Reference in new issue
Block a user