Add configurable password policy

This commit is contained in:
thePR0M3TH3AN
2025-07-13 13:00:02 -04:00
parent c0269801f8
commit b4dfd4b292
9 changed files with 145 additions and 11 deletions

View File

@@ -171,8 +171,8 @@ Code: 123456
### `config` Commands ### `config` Commands
- **`seedpass config get <key>`** 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 get <key>`** 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 <key> <value>`** Update a configuration option. Example: `seedpass config set kdf_iterations 200000`. - **`seedpass config set <key> <value>`** 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. - **`seedpass config toggle-secret-mode`** Interactively enable or disable Secret Mode and set the clipboard delay.
### `fingerprint` Commands ### `fingerprint` Commands
@@ -209,4 +209,5 @@ Shut down the server with `seedpass api stop`.
- Use the `--help` flag for details on any command. - Use the `--help` flag for details on any command.
- Set a strong master password and regularly export encrypted backups. - 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. - 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 scriptfriendly and can be piped into other commands. - `entry get` is scriptfriendly and can be piped into other commands.

View File

@@ -50,6 +50,10 @@ class ConfigManager:
"backup_interval": 0, "backup_interval": 0,
"secret_mode_enabled": False, "secret_mode_enabled": False,
"clipboard_clear_delay": 45, "clipboard_clear_delay": 45,
"min_uppercase": 2,
"min_lowercase": 2,
"min_digits": 2,
"min_special": 2,
} }
try: try:
data = self.vault.load_config() data = self.vault.load_config()
@@ -66,6 +70,10 @@ class ConfigManager:
data.setdefault("backup_interval", 0) data.setdefault("backup_interval", 0)
data.setdefault("secret_mode_enabled", False) data.setdefault("secret_mode_enabled", False)
data.setdefault("clipboard_clear_delay", 45) 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 # Migrate legacy hashed_password.enc if present and password_hash is missing
legacy_file = self.fingerprint_dir / "hashed_password.enc" legacy_file = self.fingerprint_dir / "hashed_password.enc"
@@ -218,3 +226,36 @@ class ConfigManager:
"""Retrieve the backup interval in seconds.""" """Retrieve the backup interval in seconds."""
config = self.load_config(require_pin=False) config = self.load_config(require_pin=False)
return float(config.get("backup_interval", 0)) 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)

View File

@@ -976,6 +976,7 @@ class PasswordManager:
encryption_manager=self.encryption_manager, encryption_manager=self.encryption_manager,
parent_seed=self.parent_seed, parent_seed=self.parent_seed,
bip85=self.bip85, bip85=self.bip85,
policy=self.config_manager.get_password_policy(),
) )
# Load relay configuration and initialize NostrClient # Load relay configuration and initialize NostrClient

View File

@@ -21,6 +21,7 @@ import random
import traceback import traceback
import base64 import base64
from typing import Optional from typing import Optional
from dataclasses import dataclass
from termcolor import colored from termcolor import colored
from pathlib import Path from pathlib import Path
import shutil import shutil
@@ -48,6 +49,16 @@ from password_manager.encryption import EncryptionManager
logger = logging.getLogger(__name__) 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: class PasswordGenerator:
""" """
PasswordGenerator Class PasswordGenerator Class
@@ -58,7 +69,11 @@ class PasswordGenerator:
""" """
def __init__( 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. Initializes the PasswordGenerator with the encryption manager, parent seed, and BIP85 instance.
@@ -72,6 +87,7 @@ class PasswordGenerator:
self.encryption_manager = encryption_manager self.encryption_manager = encryption_manager
self.parent_seed = parent_seed self.parent_seed = parent_seed
self.bip85 = bip85 self.bip85 = bip85
self.policy = policy or PasswordPolicy()
# Derive seed bytes from parent_seed using BIP39 (handled by EncryptionManager) # Derive seed bytes from parent_seed using BIP39 (handled by EncryptionManager)
self.seed_bytes = self.encryption_manager.derive_seed_from_mnemonic( 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}" f"Current character counts - Upper: {current_upper}, Lower: {current_lower}, Digits: {current_digits}, Special: {current_special}"
) )
# Set minimum counts # Set minimum counts from policy
min_upper = 2 min_upper = self.policy.min_uppercase
min_lower = 2 min_lower = self.policy.min_lowercase
min_digits = 2 min_digits = self.policy.min_digits
min_special = 2 min_special = self.policy.min_special
# Initialize derived key index # Initialize derived key index
dk_index = 0 dk_index = 0

View File

@@ -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_iterations": lambda v: cfg.set_kdf_iterations(int(v)),
"kdf_mode": lambda v: cfg.set_kdf_mode(v), "kdf_mode": lambda v: cfg.set_kdf_mode(v),
"backup_interval": lambda v: cfg.set_backup_interval(float(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) action = mapping.get(key)

View File

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

View File

@@ -1,5 +1,5 @@
import string import string
from password_manager.password_generation import PasswordGenerator from password_manager.password_generation import PasswordGenerator, PasswordPolicy
class DummyEnc: class DummyEnc:
@@ -16,6 +16,7 @@ def make_generator():
pg = PasswordGenerator.__new__(PasswordGenerator) pg = PasswordGenerator.__new__(PasswordGenerator)
pg.encryption_manager = DummyEnc() pg.encryption_manager = DummyEnc()
pg.bip85 = DummyBIP85() pg.bip85 = DummyBIP85()
pg.policy = PasswordPolicy()
return pg return pg

View File

@@ -4,7 +4,7 @@ import sys
sys.path.append(str(Path(__file__).resolve().parents[1])) 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 from constants import MIN_PASSWORD_LENGTH
@@ -22,6 +22,7 @@ def make_generator():
pg = PasswordGenerator.__new__(PasswordGenerator) pg = PasswordGenerator.__new__(PasswordGenerator)
pg.encryption_manager = DummyEnc() pg.encryption_manager = DummyEnc()
pg.bip85 = DummyBIP85() pg.bip85 = DummyBIP85()
pg.policy = PasswordPolicy()
return pg return pg

View File

@@ -5,7 +5,7 @@ from hypothesis import given, strategies as st, settings
sys.path.append(str(Path(__file__).resolve().parents[1])) 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 from password_manager.entry_types import EntryType
@@ -23,6 +23,7 @@ def make_generator():
pg = PasswordGenerator.__new__(PasswordGenerator) pg = PasswordGenerator.__new__(PasswordGenerator)
pg.encryption_manager = DummyEnc() pg.encryption_manager = DummyEnc()
pg.bip85 = DummyBIP85() pg.bip85 = DummyBIP85()
pg.policy = PasswordPolicy()
return pg return pg