mirror of
https://github.com/PR0M3TH3AN/SeedPass.git
synced 2025-09-07 06:48:52 +00:00
365 lines
15 KiB
Python
365 lines
15 KiB
Python
"""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))
|