mirror of
https://github.com/PR0M3TH3AN/SeedPass.git
synced 2025-09-08 07:18:47 +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:
|
def generate_password(self, length: int, index: int) -> str:
|
||||||
with self._lock:
|
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:
|
def get_totp_code(self, entry_id: int) -> str:
|
||||||
with self._lock:
|
with self._lock:
|
||||||
|
@@ -152,6 +152,15 @@ class EntryManager:
|
|||||||
notes: str = "",
|
notes: str = "",
|
||||||
custom_fields: List[Dict[str, Any]] | None = None,
|
custom_fields: List[Dict[str, Any]] | None = None,
|
||||||
tags: list[str] | 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:
|
) -> int:
|
||||||
"""
|
"""
|
||||||
Adds a new entry to the encrypted JSON index file.
|
Adds a new entry to the encrypted JSON index file.
|
||||||
@@ -169,7 +178,7 @@ class EntryManager:
|
|||||||
data = self._load_index()
|
data = self._load_index()
|
||||||
|
|
||||||
data.setdefault("entries", {})
|
data.setdefault("entries", {})
|
||||||
data["entries"][str(index)] = {
|
entry = {
|
||||||
"label": label,
|
"label": label,
|
||||||
"length": length,
|
"length": length,
|
||||||
"username": username if username else "",
|
"username": username if username else "",
|
||||||
@@ -183,6 +192,28 @@ class EntryManager:
|
|||||||
"tags": tags or [],
|
"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)]}")
|
logger.debug(f"Added entry at index {index}: {data['entries'][str(index)]}")
|
||||||
|
|
||||||
self._save_index(data)
|
self._save_index(data)
|
||||||
@@ -726,6 +757,14 @@ class EntryManager:
|
|||||||
value: Optional[str] = None,
|
value: Optional[str] = None,
|
||||||
custom_fields: List[Dict[str, Any]] | None = None,
|
custom_fields: List[Dict[str, Any]] | None = None,
|
||||||
tags: list[str] | 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,
|
**legacy,
|
||||||
) -> None:
|
) -> None:
|
||||||
"""
|
"""
|
||||||
@@ -772,6 +811,14 @@ class EntryManager:
|
|||||||
"value": value,
|
"value": value,
|
||||||
"custom_fields": custom_fields,
|
"custom_fields": custom_fields,
|
||||||
"tags": tags,
|
"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 = {
|
allowed = {
|
||||||
@@ -783,6 +830,14 @@ class EntryManager:
|
|||||||
"notes",
|
"notes",
|
||||||
"custom_fields",
|
"custom_fields",
|
||||||
"tags",
|
"tags",
|
||||||
|
"include_special_chars",
|
||||||
|
"allowed_special_chars",
|
||||||
|
"special_mode",
|
||||||
|
"exclude_ambiguous",
|
||||||
|
"min_uppercase",
|
||||||
|
"min_lowercase",
|
||||||
|
"min_digits",
|
||||||
|
"min_special",
|
||||||
},
|
},
|
||||||
EntryType.TOTP.value: {
|
EntryType.TOTP.value: {
|
||||||
"label",
|
"label",
|
||||||
@@ -908,6 +963,28 @@ class EntryManager:
|
|||||||
entry["tags"] = tags
|
entry["tags"] = tags
|
||||||
logger.debug(f"Updated tags for index {index}: {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())
|
entry["modified_ts"] = int(time.time())
|
||||||
|
|
||||||
data["entries"][str(index)] = entry
|
data["entries"][str(index)] = entry
|
||||||
|
@@ -21,6 +21,7 @@ import builtins
|
|||||||
import threading
|
import threading
|
||||||
import queue
|
import queue
|
||||||
from dataclasses import dataclass
|
from dataclasses import dataclass
|
||||||
|
import dataclasses
|
||||||
from termcolor import colored
|
from termcolor import colored
|
||||||
from utils.color_scheme import color_text
|
from utils.color_scheme import color_text
|
||||||
from utils.input_utils import timed_input
|
from utils.input_utils import timed_input
|
||||||
@@ -1457,7 +1458,8 @@ class PasswordManager:
|
|||||||
self.last_update = time.time()
|
self.last_update = time.time()
|
||||||
|
|
||||||
# Generate the password using the assigned index
|
# 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
|
# Provide user feedback
|
||||||
print(
|
print(
|
||||||
@@ -2067,6 +2069,29 @@ class PasswordManager:
|
|||||||
entry_type = entry_type.value
|
entry_type = entry_type.value
|
||||||
return str(entry_type).lower()
|
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:
|
def _entry_actions_menu(self, index: int, entry: dict) -> None:
|
||||||
"""Provide actions for a retrieved entry."""
|
"""Provide actions for a retrieved entry."""
|
||||||
while True:
|
while True:
|
||||||
@@ -2176,7 +2201,9 @@ class PasswordManager:
|
|||||||
print(colored("L. Edit Label", "cyan"))
|
print(colored("L. Edit Label", "cyan"))
|
||||||
if entry_type == EntryType.KEY_VALUE.value:
|
if entry_type == EntryType.KEY_VALUE.value:
|
||||||
print(colored("K. Edit Key", "cyan"))
|
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:
|
if entry_type == EntryType.PASSWORD.value:
|
||||||
print(colored("U. Edit Username", "cyan"))
|
print(colored("U. Edit Username", "cyan"))
|
||||||
print(colored("R. Edit URL", "cyan"))
|
print(colored("R. Edit URL", "cyan"))
|
||||||
@@ -2203,7 +2230,9 @@ class PasswordManager:
|
|||||||
if new_value:
|
if new_value:
|
||||||
self.entry_manager.modify_entry(index, value=new_value)
|
self.entry_manager.modify_entry(index, value=new_value)
|
||||||
self.is_dirty = True
|
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":
|
elif entry_type == EntryType.PASSWORD.value and choice == "u":
|
||||||
new_username = input("New username: ").strip()
|
new_username = input("New username: ").strip()
|
||||||
self.entry_manager.modify_entry(index, username=new_username)
|
self.entry_manager.modify_entry(index, username=new_username)
|
||||||
@@ -2649,7 +2678,7 @@ class PasswordManager:
|
|||||||
level="WARNING",
|
level="WARNING",
|
||||||
)
|
)
|
||||||
|
|
||||||
password = self.password_generator.generate_password(length, index)
|
password = self._generate_password_for_entry(entry, index, length)
|
||||||
|
|
||||||
if password:
|
if password:
|
||||||
if self.secret_mode_enabled:
|
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