Add per-entry password policy overrides

This commit is contained in:
thePR0M3TH3AN
2025-07-30 19:57:38 -04:00
parent f664c4099c
commit b4d60782af
4 changed files with 183 additions and 6 deletions

View File

@@ -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:

View File

@@ -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

View File

@@ -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:

View File

@@ -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)