"""Config management for SeedPass profiles.""" from __future__ import annotations import logging from pathlib import Path from typing import List, Optional from utils.seed_prompt import masked_input import bcrypt from .vault import Vault from nostr.client import DEFAULT_RELAYS as DEFAULT_NOSTR_RELAYS from constants import INACTIVITY_TIMEOUT, MAX_RETRIES, RETRY_DELAY logger = logging.getLogger(__name__) class ConfigManager: """Manage per-profile configuration encrypted on disk.""" CONFIG_FILENAME = "seedpass_config.json.enc" def __init__(self, vault: Vault, fingerprint_dir: Path): self.vault = vault self.fingerprint_dir = fingerprint_dir self.config_path = self.fingerprint_dir / self.CONFIG_FILENAME def load_config(self, require_pin: bool = True) -> dict: """Load the configuration file and optionally verify a stored PIN. Parameters ---------- require_pin: bool, default True If True and a PIN is configured, prompt the user to enter it and verify against the stored hash. """ if not self.config_path.exists(): logger.info("Config file not found; returning defaults") return { "relays": list(DEFAULT_NOSTR_RELAYS), "offline_mode": False, "pin_hash": "", "password_hash": "", "inactivity_timeout": INACTIVITY_TIMEOUT, "kdf_iterations": 50_000, "kdf_mode": "pbkdf2", "additional_backup_path": "", "backup_interval": 0, "secret_mode_enabled": False, "clipboard_clear_delay": 45, "quick_unlock": False, "nostr_max_retries": MAX_RETRIES, "nostr_retry_delay": float(RETRY_DELAY), "min_uppercase": 2, "min_lowercase": 2, "min_digits": 2, "min_special": 2, "include_special_chars": True, "allowed_special_chars": "", "special_mode": "standard", "exclude_ambiguous": False, "verbose_timing": False, } try: data = self.vault.load_config() if not isinstance(data, dict): 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("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("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("nostr_max_retries", MAX_RETRIES) data.setdefault("nostr_retry_delay", float(RETRY_DELAY)) data.setdefault("min_uppercase", 2) data.setdefault("min_lowercase", 2) data.setdefault("min_digits", 2) data.setdefault("min_special", 2) data.setdefault("include_special_chars", True) data.setdefault("allowed_special_chars", "") data.setdefault("special_mode", "standard") data.setdefault("exclude_ambiguous", False) data.setdefault("verbose_timing", False) # Migrate legacy hashed_password.enc if present and password_hash is missing legacy_file = self.fingerprint_dir / "hashed_password.enc" if not data.get("password_hash") and legacy_file.exists(): with open(legacy_file, "rb") as f: data["password_hash"] = f.read().decode() self.save_config(data) if require_pin and data.get("pin_hash"): for _ in range(3): pin = masked_input("Enter settings PIN: ").strip() if bcrypt.checkpw(pin.encode(), data["pin_hash"].encode()): break print("Invalid PIN") else: raise ValueError("PIN verification failed") return data except Exception as exc: logger.error(f"Failed to load config: {exc}") raise def save_config(self, config: dict) -> None: """Encrypt and save configuration.""" try: config.setdefault("backup_interval", 0) self.vault.save_config(config) except Exception as exc: logger.error(f"Failed to save config: {exc}") raise def set_relays(self, relays: List[str], require_pin: bool = True) -> None: """Update relay list and save.""" if not relays: raise ValueError("At least one Nostr relay must be configured") config = self.load_config(require_pin=require_pin) config["relays"] = relays self.save_config(config) def set_pin(self, pin: str) -> None: """Hash and store the provided PIN.""" pin_hash = bcrypt.hashpw(pin.encode(), bcrypt.gensalt()).decode() config = self.load_config(require_pin=False) config["pin_hash"] = pin_hash self.save_config(config) def verify_pin(self, pin: str) -> bool: """Check a provided PIN against the stored hash without prompting.""" config = self.load_config(require_pin=False) stored = config.get("pin_hash", "").encode() if not stored: return False return bcrypt.checkpw(pin.encode(), stored) def change_pin(self, old_pin: str, new_pin: str) -> bool: """Update the stored PIN if the old PIN is correct.""" if self.verify_pin(old_pin): self.set_pin(new_pin) return True return False def set_password_hash(self, password_hash: str) -> None: """Persist the bcrypt password hash in the config.""" config = self.load_config(require_pin=False) config["password_hash"] = password_hash self.save_config(config) def set_inactivity_timeout(self, timeout_seconds: float) -> None: """Persist the inactivity timeout in seconds.""" if timeout_seconds <= 0: raise ValueError("Timeout must be positive") config = self.load_config(require_pin=False) config["inactivity_timeout"] = timeout_seconds self.save_config(config) def get_inactivity_timeout(self) -> float: """Retrieve the inactivity timeout setting in seconds.""" config = self.load_config(require_pin=False) return float(config.get("inactivity_timeout", INACTIVITY_TIMEOUT)) def set_kdf_iterations(self, iterations: int) -> None: """Persist the PBKDF2 iteration count in the config.""" if iterations <= 0: raise ValueError("Iterations must be positive") config = self.load_config(require_pin=False) config["kdf_iterations"] = int(iterations) self.save_config(config) def get_kdf_iterations(self) -> int: """Retrieve the PBKDF2 iteration count.""" config = self.load_config(require_pin=False) return int(config.get("kdf_iterations", 50_000)) def set_kdf_mode(self, mode: str) -> None: """Persist the key derivation function mode.""" if mode not in ("pbkdf2", "argon2"): raise ValueError("kdf_mode must be 'pbkdf2' or 'argon2'") config = self.load_config(require_pin=False) config["kdf_mode"] = mode self.save_config(config) def get_kdf_mode(self) -> str: """Retrieve the configured key derivation function.""" config = self.load_config(require_pin=False) return config.get("kdf_mode", "pbkdf2") 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) config["additional_backup_path"] = path or "" self.save_config(config) def get_additional_backup_path(self) -> Optional[str]: """Retrieve the additional backup path if configured.""" config = self.load_config(require_pin=False) value = config.get("additional_backup_path", "") return value or None def set_secret_mode_enabled(self, enabled: bool) -> None: """Persist the secret mode toggle.""" config = self.load_config(require_pin=False) config["secret_mode_enabled"] = bool(enabled) self.save_config(config) def set_offline_mode(self, enabled: bool) -> None: """Persist the offline mode toggle.""" config = self.load_config(require_pin=False) config["offline_mode"] = bool(enabled) self.save_config(config) def get_secret_mode_enabled(self) -> bool: """Retrieve whether secret mode is enabled.""" config = self.load_config(require_pin=False) return bool(config.get("secret_mode_enabled", False)) 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)) def set_clipboard_clear_delay(self, delay: int) -> None: """Persist clipboard clear timeout in seconds.""" if delay <= 0: raise ValueError("Delay must be positive") config = self.load_config(require_pin=False) config["clipboard_clear_delay"] = int(delay) self.save_config(config) def get_clipboard_clear_delay(self) -> int: """Retrieve clipboard clear delay in seconds.""" config = self.load_config(require_pin=False) return int(config.get("clipboard_clear_delay", 45)) def set_backup_interval(self, interval: int | float) -> None: """Persist the minimum interval in seconds between automatic backups.""" if interval < 0: raise ValueError("Interval cannot be negative") config = self.load_config(require_pin=False) config["backup_interval"] = interval self.save_config(config) def get_backup_interval(self) -> float: """Retrieve the backup interval in seconds.""" config = self.load_config(require_pin=False) return float(config.get("backup_interval", 0)) # Password policy settings def get_password_policy(self) -> "PasswordPolicy": """Return the password complexity policy.""" from .password_generation import PasswordPolicy cfg = self.load_config(require_pin=False) return PasswordPolicy( min_uppercase=int(cfg.get("min_uppercase", 2)), min_lowercase=int(cfg.get("min_lowercase", 2)), min_digits=int(cfg.get("min_digits", 2)), min_special=int(cfg.get("min_special", 2)), include_special_chars=bool(cfg.get("include_special_chars", True)), allowed_special_chars=cfg.get("allowed_special_chars") or None, special_mode=cfg.get("special_mode") or None, exclude_ambiguous=bool(cfg.get("exclude_ambiguous", False)), ) def set_min_uppercase(self, count: int) -> None: cfg = self.load_config(require_pin=False) cfg["min_uppercase"] = int(count) self.save_config(cfg) def set_min_lowercase(self, count: int) -> None: cfg = self.load_config(require_pin=False) cfg["min_lowercase"] = int(count) self.save_config(cfg) def set_min_digits(self, count: int) -> None: cfg = self.load_config(require_pin=False) cfg["min_digits"] = int(count) self.save_config(cfg) def set_min_special(self, count: int) -> None: cfg = self.load_config(require_pin=False) cfg["min_special"] = int(count) self.save_config(cfg) def set_include_special_chars(self, enabled: bool) -> None: """Persist whether special characters are allowed.""" cfg = self.load_config(require_pin=False) cfg["include_special_chars"] = bool(enabled) self.save_config(cfg) def set_allowed_special_chars(self, chars: str | None) -> None: """Persist the set of allowed special characters.""" cfg = self.load_config(require_pin=False) cfg["allowed_special_chars"] = chars or "" self.save_config(cfg) def set_special_mode(self, mode: str) -> None: """Persist the special character mode.""" cfg = self.load_config(require_pin=False) cfg["special_mode"] = mode self.save_config(cfg) def set_exclude_ambiguous(self, enabled: bool) -> None: """Persist whether ambiguous characters are excluded.""" cfg = self.load_config(require_pin=False) cfg["exclude_ambiguous"] = bool(enabled) self.save_config(cfg) 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) 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)) def set_nostr_max_retries(self, retries: int) -> None: """Persist the maximum number of Nostr retry attempts.""" if retries < 0: raise ValueError("retries cannot be negative") cfg = self.load_config(require_pin=False) cfg["nostr_max_retries"] = int(retries) self.save_config(cfg) def get_nostr_max_retries(self) -> int: """Retrieve the configured Nostr retry count.""" cfg = self.load_config(require_pin=False) return int(cfg.get("nostr_max_retries", MAX_RETRIES)) def set_nostr_retry_delay(self, delay: float) -> None: """Persist the delay between Nostr retry attempts.""" if delay < 0: raise ValueError("delay cannot be negative") cfg = self.load_config(require_pin=False) cfg["nostr_retry_delay"] = float(delay) self.save_config(cfg) def get_nostr_retry_delay(self) -> float: """Retrieve the delay in seconds between Nostr retries.""" cfg = self.load_config(require_pin=False) return float(cfg.get("nostr_retry_delay", float(RETRY_DELAY))) def set_verbose_timing(self, enabled: bool) -> None: cfg = self.load_config(require_pin=False) cfg["verbose_timing"] = bool(enabled) self.save_config(cfg) def get_verbose_timing(self) -> bool: cfg = self.load_config(require_pin=False) return bool(cfg.get("verbose_timing", False))