mirror of
https://github.com/PR0M3TH3AN/SeedPass.git
synced 2025-09-07 06:48:52 +00:00
Add per-entry password policy overrides
This commit is contained in:
@@ -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:
|
||||
|
@@ -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
|
||||
|
@@ -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:
|
||||
|
67
src/tests/test_entry_policy_override.py
Normal file
67
src/tests/test_entry_policy_override.py
Normal 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)
|
Reference in New Issue
Block a user