diff --git a/docs/docs/content/01-getting-started/01-advanced_cli.md b/docs/docs/content/01-getting-started/01-advanced_cli.md index 8ac0258..7b71add 100644 --- a/docs/docs/content/01-getting-started/01-advanced_cli.md +++ b/docs/docs/content/01-getting-started/01-advanced_cli.md @@ -171,8 +171,8 @@ Code: 123456 ### `config` Commands -- **`seedpass config get `** – Retrieve a configuration value such as `kdf_iterations`, `backup_interval`, `inactivity_timeout`, `secret_mode_enabled`, `clipboard_clear_delay`, `additional_backup_path`, or `relays`. -- **`seedpass config set `** – Update a configuration option. Example: `seedpass config set kdf_iterations 200000`. +- **`seedpass config get `** – Retrieve a configuration value such as `kdf_iterations`, `backup_interval`, `inactivity_timeout`, `secret_mode_enabled`, `clipboard_clear_delay`, `additional_backup_path`, `relays`, or password policy fields like `min_uppercase`. +- **`seedpass config set `** – Update a configuration option. Example: `seedpass config set kdf_iterations 200000`. Use keys like `min_uppercase`, `min_lowercase`, `min_digits`, or `min_special` to adjust password complexity. - **`seedpass config toggle-secret-mode`** – Interactively enable or disable Secret Mode and set the clipboard delay. ### `fingerprint` Commands @@ -209,4 +209,5 @@ Shut down the server with `seedpass api stop`. - Use the `--help` flag for details on any command. - Set a strong master password and regularly export encrypted backups. - Adjust configuration values like `kdf_iterations`, `backup_interval`, `inactivity_timeout`, or `secret_mode_enabled` through the `config` commands. +- Customize password complexity with `config set min_uppercase 3`, `config set min_digits 4`, and similar commands. - `entry get` is script‑friendly and can be piped into other commands. diff --git a/src/password_manager/config_manager.py b/src/password_manager/config_manager.py index 18a46ab..7486012 100644 --- a/src/password_manager/config_manager.py +++ b/src/password_manager/config_manager.py @@ -50,6 +50,10 @@ class ConfigManager: "backup_interval": 0, "secret_mode_enabled": False, "clipboard_clear_delay": 45, + "min_uppercase": 2, + "min_lowercase": 2, + "min_digits": 2, + "min_special": 2, } try: data = self.vault.load_config() @@ -66,6 +70,10 @@ class ConfigManager: data.setdefault("backup_interval", 0) data.setdefault("secret_mode_enabled", False) data.setdefault("clipboard_clear_delay", 45) + data.setdefault("min_uppercase", 2) + data.setdefault("min_lowercase", 2) + data.setdefault("min_digits", 2) + data.setdefault("min_special", 2) # Migrate legacy hashed_password.enc if present and password_hash is missing legacy_file = self.fingerprint_dir / "hashed_password.enc" @@ -218,3 +226,36 @@ class ConfigManager: """Retrieve the backup interval in seconds.""" config = self.load_config(require_pin=False) return float(config.get("backup_interval", 0)) + + # Password policy settings + def get_password_policy(self) -> "PasswordPolicy": + """Return the password complexity policy.""" + from password_manager.password_generation import PasswordPolicy + + cfg = self.load_config(require_pin=False) + return PasswordPolicy( + min_uppercase=int(cfg.get("min_uppercase", 2)), + min_lowercase=int(cfg.get("min_lowercase", 2)), + min_digits=int(cfg.get("min_digits", 2)), + min_special=int(cfg.get("min_special", 2)), + ) + + def set_min_uppercase(self, count: int) -> None: + cfg = self.load_config(require_pin=False) + cfg["min_uppercase"] = int(count) + self.save_config(cfg) + + def set_min_lowercase(self, count: int) -> None: + cfg = self.load_config(require_pin=False) + cfg["min_lowercase"] = int(count) + self.save_config(cfg) + + def set_min_digits(self, count: int) -> None: + cfg = self.load_config(require_pin=False) + cfg["min_digits"] = int(count) + self.save_config(cfg) + + def set_min_special(self, count: int) -> None: + cfg = self.load_config(require_pin=False) + cfg["min_special"] = int(count) + self.save_config(cfg) diff --git a/src/password_manager/manager.py b/src/password_manager/manager.py index 65a044f..ceabd04 100644 --- a/src/password_manager/manager.py +++ b/src/password_manager/manager.py @@ -976,6 +976,7 @@ class PasswordManager: encryption_manager=self.encryption_manager, parent_seed=self.parent_seed, bip85=self.bip85, + policy=self.config_manager.get_password_policy(), ) # Load relay configuration and initialize NostrClient diff --git a/src/password_manager/password_generation.py b/src/password_manager/password_generation.py index a5a3f91..b61523f 100644 --- a/src/password_manager/password_generation.py +++ b/src/password_manager/password_generation.py @@ -21,6 +21,7 @@ import random import traceback import base64 from typing import Optional +from dataclasses import dataclass from termcolor import colored from pathlib import Path import shutil @@ -48,6 +49,16 @@ from password_manager.encryption import EncryptionManager logger = logging.getLogger(__name__) +@dataclass +class PasswordPolicy: + """Minimum complexity requirements for generated passwords.""" + + min_uppercase: int = 2 + min_lowercase: int = 2 + min_digits: int = 2 + min_special: int = 2 + + class PasswordGenerator: """ PasswordGenerator Class @@ -58,7 +69,11 @@ class PasswordGenerator: """ def __init__( - self, encryption_manager: EncryptionManager, parent_seed: str, bip85: BIP85 + self, + encryption_manager: EncryptionManager, + parent_seed: str, + bip85: BIP85, + policy: PasswordPolicy | None = None, ): """ Initializes the PasswordGenerator with the encryption manager, parent seed, and BIP85 instance. @@ -72,6 +87,7 @@ class PasswordGenerator: self.encryption_manager = encryption_manager self.parent_seed = parent_seed self.bip85 = bip85 + self.policy = policy or PasswordPolicy() # Derive seed bytes from parent_seed using BIP39 (handled by EncryptionManager) self.seed_bytes = self.encryption_manager.derive_seed_from_mnemonic( @@ -224,11 +240,11 @@ class PasswordGenerator: f"Current character counts - Upper: {current_upper}, Lower: {current_lower}, Digits: {current_digits}, Special: {current_special}" ) - # Set minimum counts - min_upper = 2 - min_lower = 2 - min_digits = 2 - min_special = 2 + # Set minimum counts from policy + min_upper = self.policy.min_uppercase + min_lower = self.policy.min_lowercase + min_digits = self.policy.min_digits + min_special = self.policy.min_special # Initialize derived key index dk_index = 0 diff --git a/src/seedpass/cli.py b/src/seedpass/cli.py index 27776e1..6c93192 100644 --- a/src/seedpass/cli.py +++ b/src/seedpass/cli.py @@ -464,6 +464,10 @@ def config_set(ctx: typer.Context, key: str, value: str) -> None: "kdf_iterations": lambda v: cfg.set_kdf_iterations(int(v)), "kdf_mode": lambda v: cfg.set_kdf_mode(v), "backup_interval": lambda v: cfg.set_backup_interval(float(v)), + "min_uppercase": lambda v: cfg.set_min_uppercase(int(v)), + "min_lowercase": lambda v: cfg.set_min_lowercase(int(v)), + "min_digits": lambda v: cfg.set_min_digits(int(v)), + "min_special": lambda v: cfg.set_min_special(int(v)), } action = mapping.get(key) diff --git a/src/tests/test_password_generation_policy.py b/src/tests/test_password_generation_policy.py new file mode 100644 index 0000000..5384075 --- /dev/null +++ b/src/tests/test_password_generation_policy.py @@ -0,0 +1,68 @@ +import string +from pathlib import Path +import sys + +sys.path.append(str(Path(__file__).resolve().parents[1])) + +from password_manager.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_generator(policy=None): + pg = PasswordGenerator.__new__(PasswordGenerator) + pg.encryption_manager = DummyEnc() + pg.bip85 = DummyBIP85() + pg.policy = policy or PasswordPolicy() + return pg + + +def count_types(pw: str): + return ( + sum(c.isupper() for c in pw), + sum(c.islower() for c in pw), + sum(c.isdigit() for c in pw), + sum(c in string.punctuation for c in pw), + ) + + +def test_zero_policy_preserves_length(): + policy = PasswordPolicy(0, 0, 0, 0) + pg = make_generator(policy) + alphabet = string.ascii_lowercase + dk = bytes(range(32)) + result = pg._enforce_complexity("a" * 32, alphabet, dk) + assert len(result) == 32 + + +def test_custom_policy_applied(): + policy = PasswordPolicy( + min_uppercase=4, min_lowercase=1, min_digits=3, min_special=2 + ) + pg = make_generator(policy) + alphabet = string.ascii_letters + string.digits + string.punctuation + dk = bytes(range(32)) + result = pg._enforce_complexity("a" * 32, alphabet, dk) + counts = count_types(result) + assert counts[0] >= 4 + assert counts[1] >= 1 + assert counts[2] >= 3 + assert counts[3] >= 2 + + +def test_generate_password_respects_policy(): + policy = PasswordPolicy( + min_uppercase=3, min_lowercase=3, min_digits=3, min_special=3 + ) + pg = make_generator(policy) + pw = pg.generate_password(length=16, index=1) + counts = count_types(pw) + assert all(c >= 3 for c in counts) diff --git a/src/tests/test_password_helpers.py b/src/tests/test_password_helpers.py index 9253130..d6f661c 100644 --- a/src/tests/test_password_helpers.py +++ b/src/tests/test_password_helpers.py @@ -1,5 +1,5 @@ import string -from password_manager.password_generation import PasswordGenerator +from password_manager.password_generation import PasswordGenerator, PasswordPolicy class DummyEnc: @@ -16,6 +16,7 @@ def make_generator(): pg = PasswordGenerator.__new__(PasswordGenerator) pg.encryption_manager = DummyEnc() pg.bip85 = DummyBIP85() + pg.policy = PasswordPolicy() return pg diff --git a/src/tests/test_password_length_constraints.py b/src/tests/test_password_length_constraints.py index db38702..eaa4941 100644 --- a/src/tests/test_password_length_constraints.py +++ b/src/tests/test_password_length_constraints.py @@ -4,7 +4,7 @@ import sys sys.path.append(str(Path(__file__).resolve().parents[1])) -from password_manager.password_generation import PasswordGenerator +from password_manager.password_generation import PasswordGenerator, PasswordPolicy from constants import MIN_PASSWORD_LENGTH @@ -22,6 +22,7 @@ def make_generator(): pg = PasswordGenerator.__new__(PasswordGenerator) pg.encryption_manager = DummyEnc() pg.bip85 = DummyBIP85() + pg.policy = PasswordPolicy() return pg diff --git a/src/tests/test_password_properties.py b/src/tests/test_password_properties.py index f51dd5f..60fce89 100644 --- a/src/tests/test_password_properties.py +++ b/src/tests/test_password_properties.py @@ -5,7 +5,7 @@ from hypothesis import given, strategies as st, settings sys.path.append(str(Path(__file__).resolve().parents[1])) -from password_manager.password_generation import PasswordGenerator +from password_manager.password_generation import PasswordGenerator, PasswordPolicy from password_manager.entry_types import EntryType @@ -23,6 +23,7 @@ def make_generator(): pg = PasswordGenerator.__new__(PasswordGenerator) pg.encryption_manager = DummyEnc() pg.bip85 = DummyBIP85() + pg.policy = PasswordPolicy() return pg