From b4d60782af0f8aba8e1f8d686fba7da4f0d846b0 Mon Sep 17 00:00:00 2001 From: thePR0M3TH3AN <53631862+PR0M3TH3AN@users.noreply.github.com> Date: Wed, 30 Jul 2025 19:57:38 -0400 Subject: [PATCH] Add per-entry password policy overrides --- src/seedpass/core/api.py | 6 +- src/seedpass/core/entry_management.py | 79 ++++++++++++++++++++++++- src/seedpass/core/manager.py | 37 ++++++++++-- src/tests/test_entry_policy_override.py | 67 +++++++++++++++++++++ 4 files changed, 183 insertions(+), 6 deletions(-) create mode 100644 src/tests/test_entry_policy_override.py diff --git a/src/seedpass/core/api.py b/src/seedpass/core/api.py index 7415bce..04c5837 100644 --- a/src/seedpass/core/api.py +++ b/src/seedpass/core/api.py @@ -265,7 +265,11 @@ class EntryService: def generate_password(self, length: int, index: int) -> str: with self._lock: - return self._manager.password_generator.generate_password(length, index) + entry = self._manager.entry_manager.retrieve_entry(index) + gen_fn = getattr(self._manager, "_generate_password_for_entry", None) + if gen_fn is None: + return self._manager.password_generator.generate_password(length, index) + return gen_fn(entry, index, length) def get_totp_code(self, entry_id: int) -> str: with self._lock: diff --git a/src/seedpass/core/entry_management.py b/src/seedpass/core/entry_management.py index bbac6a4..c4e6d53 100644 --- a/src/seedpass/core/entry_management.py +++ b/src/seedpass/core/entry_management.py @@ -152,6 +152,15 @@ class EntryManager: notes: str = "", custom_fields: List[Dict[str, Any]] | None = None, tags: list[str] | None = None, + *, + include_special_chars: bool | None = None, + allowed_special_chars: str | None = None, + special_mode: str | None = None, + exclude_ambiguous: bool | None = None, + min_uppercase: int | None = None, + min_lowercase: int | None = None, + min_digits: int | None = None, + min_special: int | None = None, ) -> int: """ Adds a new entry to the encrypted JSON index file. @@ -169,7 +178,7 @@ class EntryManager: data = self._load_index() data.setdefault("entries", {}) - data["entries"][str(index)] = { + entry = { "label": label, "length": length, "username": username if username else "", @@ -183,6 +192,28 @@ class EntryManager: "tags": tags or [], } + policy: dict[str, Any] = {} + if include_special_chars is not None: + policy["include_special_chars"] = include_special_chars + if allowed_special_chars is not None: + policy["allowed_special_chars"] = allowed_special_chars + if special_mode is not None: + policy["special_mode"] = special_mode + if exclude_ambiguous is not None: + policy["exclude_ambiguous"] = exclude_ambiguous + if min_uppercase is not None: + policy["min_uppercase"] = int(min_uppercase) + if min_lowercase is not None: + policy["min_lowercase"] = int(min_lowercase) + if min_digits is not None: + policy["min_digits"] = int(min_digits) + if min_special is not None: + policy["min_special"] = int(min_special) + if policy: + entry["policy"] = policy + + data["entries"][str(index)] = entry + logger.debug(f"Added entry at index {index}: {data['entries'][str(index)]}") self._save_index(data) @@ -726,6 +757,14 @@ class EntryManager: value: Optional[str] = None, custom_fields: List[Dict[str, Any]] | None = None, tags: list[str] | None = None, + include_special_chars: bool | None = None, + allowed_special_chars: str | None = None, + special_mode: str | None = None, + exclude_ambiguous: bool | None = None, + min_uppercase: int | None = None, + min_lowercase: int | None = None, + min_digits: int | None = None, + min_special: int | None = None, **legacy, ) -> None: """ @@ -772,6 +811,14 @@ class EntryManager: "value": value, "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, } allowed = { @@ -783,6 +830,14 @@ class EntryManager: "notes", "custom_fields", "tags", + "include_special_chars", + "allowed_special_chars", + "special_mode", + "exclude_ambiguous", + "min_uppercase", + "min_lowercase", + "min_digits", + "min_special", }, EntryType.TOTP.value: { "label", @@ -908,6 +963,28 @@ class EntryManager: entry["tags"] = tags logger.debug(f"Updated tags for index {index}: {tags}") + policy_updates: dict[str, Any] = {} + if include_special_chars is not None: + policy_updates["include_special_chars"] = include_special_chars + if allowed_special_chars is not None: + policy_updates["allowed_special_chars"] = allowed_special_chars + if special_mode is not None: + policy_updates["special_mode"] = special_mode + if exclude_ambiguous is not None: + policy_updates["exclude_ambiguous"] = exclude_ambiguous + if min_uppercase is not None: + policy_updates["min_uppercase"] = int(min_uppercase) + if min_lowercase is not None: + policy_updates["min_lowercase"] = int(min_lowercase) + if min_digits is not None: + policy_updates["min_digits"] = int(min_digits) + if min_special is not None: + policy_updates["min_special"] = int(min_special) + if policy_updates: + entry_policy = entry.get("policy", {}) + entry_policy.update(policy_updates) + entry["policy"] = entry_policy + entry["modified_ts"] = int(time.time()) data["entries"][str(index)] = entry diff --git a/src/seedpass/core/manager.py b/src/seedpass/core/manager.py index 1491e98..dc61107 100644 --- a/src/seedpass/core/manager.py +++ b/src/seedpass/core/manager.py @@ -21,6 +21,7 @@ import builtins import threading import queue from dataclasses import dataclass +import dataclasses from termcolor import colored from utils.color_scheme import color_text from utils.input_utils import timed_input @@ -1457,7 +1458,8 @@ class PasswordManager: self.last_update = time.time() # Generate the password using the assigned index - password = self.password_generator.generate_password(length, index) + entry = self.entry_manager.retrieve_entry(index) + password = self._generate_password_for_entry(entry, index, length) # Provide user feedback print( @@ -2067,6 +2069,29 @@ class PasswordManager: entry_type = entry_type.value return str(entry_type).lower() + def _generate_password_for_entry( + self, entry: dict, index: int, length: int | None = None + ) -> str: + """Generate a password for ``entry`` honoring any policy overrides.""" + if length is None: + length = int(entry.get("length", DEFAULT_PASSWORD_LENGTH)) + overrides = entry.get("policy", {}) + + pg = self.password_generator + if not hasattr(pg, "policy") or not isinstance(overrides, dict): + return pg.generate_password(length, index) + + base_policy = pg.policy + merged = dataclasses.replace( + base_policy, + **{k: overrides[k] for k in overrides if hasattr(base_policy, k)}, + ) + pg.policy = merged + try: + return pg.generate_password(length, index) + finally: + pg.policy = base_policy + def _entry_actions_menu(self, index: int, entry: dict) -> None: """Provide actions for a retrieved entry.""" while True: @@ -2176,7 +2201,9 @@ class PasswordManager: print(colored("L. Edit Label", "cyan")) if entry_type == EntryType.KEY_VALUE.value: print(colored("K. Edit Key", "cyan")) - print(colored("V. Edit Value", "cyan")) # 🔧 merged conflicting changes from feature-X vs main + print( + colored("V. Edit Value", "cyan") + ) # 🔧 merged conflicting changes from feature-X vs main if entry_type == EntryType.PASSWORD.value: print(colored("U. Edit Username", "cyan")) print(colored("R. Edit URL", "cyan")) @@ -2203,7 +2230,9 @@ class PasswordManager: if new_value: self.entry_manager.modify_entry(index, value=new_value) self.is_dirty = True - self.last_update = time.time() # 🔧 merged conflicting changes from feature-X vs main + self.last_update = ( + time.time() + ) # 🔧 merged conflicting changes from feature-X vs main elif entry_type == EntryType.PASSWORD.value and choice == "u": new_username = input("New username: ").strip() self.entry_manager.modify_entry(index, username=new_username) @@ -2649,7 +2678,7 @@ class PasswordManager: level="WARNING", ) - password = self.password_generator.generate_password(length, index) + password = self._generate_password_for_entry(entry, index, length) if password: if self.secret_mode_enabled: diff --git a/src/tests/test_entry_policy_override.py b/src/tests/test_entry_policy_override.py new file mode 100644 index 0000000..c23c1c8 --- /dev/null +++ b/src/tests/test_entry_policy_override.py @@ -0,0 +1,67 @@ +import sys +from pathlib import Path +from tempfile import TemporaryDirectory +from types import SimpleNamespace +import string + +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 +from seedpass.core.password_generation import PasswordGenerator, PasswordPolicy + + +class DummyEnc: + def derive_seed_from_mnemonic(self, mnemonic): + 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 make_manager(tmp_path: Path) -> PasswordManager: + 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) + + pg = PasswordGenerator.__new__(PasswordGenerator) + pg.encryption_manager = DummyEnc() + pg.bip85 = DummyBIP85() + pg.policy = PasswordPolicy( + min_uppercase=0, min_lowercase=0, min_digits=1, min_special=0 + ) + + pm = PasswordManager.__new__(PasswordManager) + pm.encryption_mode = EncryptionMode.SEED_ONLY + pm.password_generator = pg + pm.entry_manager = entry_mgr + pm.parent_seed = TEST_SEED + pm.vault = vault + pm.backup_manager = backup_mgr + pm.nostr_client = SimpleNamespace() + pm.fingerprint_dir = tmp_path + pm.secret_mode_enabled = False + return pm + + +def test_entry_policy_override_changes_password(): + with TemporaryDirectory() as tmpdir: + tmp_path = Path(tmpdir) + pm = make_manager(tmp_path) + idx = pm.entry_manager.add_entry( + "site", + 16, + min_digits=5, + include_special_chars=False, + ) + entry = pm.entry_manager.retrieve_entry(idx) + pw = pm._generate_password_for_entry(entry, idx) + assert sum(c.isdigit() for c in pw) >= 5 + assert not any(c in string.punctuation for c in pw)