diff --git a/src/seedpass/core/api.py b/src/seedpass/core/api.py index eb77092..7415bce 100644 --- a/src/seedpass/core/api.py +++ b/src/seedpass/core/api.py @@ -488,6 +488,16 @@ class ConfigService: "min_lowercase": ("set_min_lowercase", int), "min_digits": ("set_min_digits", int), "min_special": ("set_min_special", int), + "include_special_chars": ( + "set_include_special_chars", + lambda v: v.lower() in ("1", "true", "yes", "y", "on"), + ), + "allowed_special_chars": ("set_allowed_special_chars", lambda v: v), + "special_mode": ("set_special_mode", lambda v: v), + "exclude_ambiguous": ( + "set_exclude_ambiguous", + lambda v: v.lower() in ("1", "true", "yes", "y", "on"), + ), "quick_unlock": ( "set_quick_unlock", lambda v: v.lower() in ("1", "true", "yes", "y", "on"), diff --git a/src/seedpass/core/config_manager.py b/src/seedpass/core/config_manager.py index a474277..31b469c 100644 --- a/src/seedpass/core/config_manager.py +++ b/src/seedpass/core/config_manager.py @@ -58,6 +58,10 @@ class ConfigManager: "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: @@ -83,6 +87,10 @@ class ConfigManager: 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 @@ -259,6 +267,10 @@ class ConfigManager: 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: @@ -281,6 +293,30 @@ class ConfigManager: 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) diff --git a/src/tests/test_config_manager.py b/src/tests/test_config_manager.py index c6ee18e..f1f4b0e 100644 --- a/src/tests/test_config_manager.py +++ b/src/tests/test_config_manager.py @@ -196,3 +196,43 @@ def test_nostr_retry_settings_round_trip(): cfg_mgr.set_nostr_retry_delay(3.5) assert cfg_mgr.get_nostr_max_retries() == 5 assert cfg_mgr.get_nostr_retry_delay() == 3.5 + + +def test_special_char_settings_round_trip(): + with TemporaryDirectory() as tmpdir: + vault, _ = create_vault(Path(tmpdir), TEST_SEED, TEST_PASSWORD) + cfg_mgr = ConfigManager(vault, Path(tmpdir)) + + cfg = cfg_mgr.load_config(require_pin=False) + assert cfg["include_special_chars"] is True + assert cfg["allowed_special_chars"] == "" + assert cfg["special_mode"] == "standard" + assert cfg["exclude_ambiguous"] is False + + cfg_mgr.set_include_special_chars(False) + cfg_mgr.set_allowed_special_chars("@$") + cfg_mgr.set_special_mode("safe") + cfg_mgr.set_exclude_ambiguous(True) + + cfg2 = cfg_mgr.load_config(require_pin=False) + assert cfg2["include_special_chars"] is False + assert cfg2["allowed_special_chars"] == "@$" + assert cfg2["special_mode"] == "safe" + assert cfg2["exclude_ambiguous"] is True + + +def test_password_policy_extended_fields(): + with TemporaryDirectory() as tmpdir: + vault, _ = create_vault(Path(tmpdir), TEST_SEED, TEST_PASSWORD) + cfg_mgr = ConfigManager(vault, Path(tmpdir)) + + cfg_mgr.set_include_special_chars(False) + cfg_mgr.set_allowed_special_chars("()") + cfg_mgr.set_special_mode("safe") + cfg_mgr.set_exclude_ambiguous(True) + + policy = cfg_mgr.get_password_policy() + assert policy.include_special_chars is False + assert policy.allowed_special_chars == "()" + assert policy.special_mode == "safe" + assert policy.exclude_ambiguous is True