From b57e19b65738a6aec84e345dd529b50f73761ba4 Mon Sep 17 00:00:00 2001 From: thePR0M3TH3AN <53631862+PR0M3TH3AN@users.noreply.github.com> Date: Wed, 30 Jul 2025 20:22:53 -0400 Subject: [PATCH] feat: extend password options --- src/seedpass/cli.py | 87 +++++++++++++++++++++++- src/seedpass/core/api.py | 83 ++++++++++++++++++++-- src/tests/test_cli_entry_add_commands.py | 25 ++++++- src/tests/test_typer_cli.py | 75 ++++++++++++++++++-- 4 files changed, 255 insertions(+), 15 deletions(-) diff --git a/src/seedpass/cli.py b/src/seedpass/cli.py index 4ab377d..b989905 100644 --- a/src/seedpass/cli.py +++ b/src/seedpass/cli.py @@ -209,10 +209,49 @@ def entry_add( length: int = typer.Option(12, "--length"), username: Optional[str] = typer.Option(None, "--username"), url: Optional[str] = typer.Option(None, "--url"), + no_special: bool = typer.Option( + False, "--no-special", help="Exclude special characters", is_flag=True + ), + allowed_special_chars: Optional[str] = typer.Option( + None, "--allowed-special-chars", help="Explicit set of special characters" + ), + special_mode: Optional[str] = typer.Option( + None, + "--special-mode", + help="Special character mode", + ), + exclude_ambiguous: bool = typer.Option( + False, + "--exclude-ambiguous", + help="Exclude ambiguous characters", + is_flag=True, + ), + min_uppercase: Optional[int] = typer.Option(None, "--min-uppercase"), + min_lowercase: Optional[int] = typer.Option(None, "--min-lowercase"), + min_digits: Optional[int] = typer.Option(None, "--min-digits"), + min_special: Optional[int] = typer.Option(None, "--min-special"), ) -> None: """Add a new password entry and output its index.""" service = _get_entry_service(ctx) - index = service.add_entry(label, length, username, url) + kwargs = {} + if no_special: + kwargs["include_special_chars"] = False + if allowed_special_chars is not None: + kwargs["allowed_special_chars"] = allowed_special_chars + if special_mode is not None: + kwargs["special_mode"] = special_mode + if exclude_ambiguous: + kwargs["exclude_ambiguous"] = True + if min_uppercase is not None: + kwargs["min_uppercase"] = min_uppercase + if min_lowercase is not None: + kwargs["min_lowercase"] = min_lowercase + if min_digits is not None: + kwargs["min_digits"] = min_digits + if min_special is not None: + kwargs["min_special"] = min_special + + index = service.add_entry(label, length, username, url, **kwargs) typer.echo(str(index)) @@ -708,10 +747,52 @@ def fingerprint_switch(ctx: typer.Context, fingerprint: str) -> None: @util_app.command("generate-password") -def generate_password(ctx: typer.Context, length: int = 24) -> None: +def generate_password( + ctx: typer.Context, + length: int = 24, + no_special: bool = typer.Option( + False, "--no-special", help="Exclude special characters", is_flag=True + ), + allowed_special_chars: Optional[str] = typer.Option( + None, "--allowed-special-chars", help="Explicit set of special characters" + ), + special_mode: Optional[str] = typer.Option( + None, + "--special-mode", + help="Special character mode", + ), + exclude_ambiguous: bool = typer.Option( + False, + "--exclude-ambiguous", + help="Exclude ambiguous characters", + is_flag=True, + ), + min_uppercase: Optional[int] = typer.Option(None, "--min-uppercase"), + min_lowercase: Optional[int] = typer.Option(None, "--min-lowercase"), + min_digits: Optional[int] = typer.Option(None, "--min-digits"), + min_special: Optional[int] = typer.Option(None, "--min-special"), +) -> None: """Generate a strong password.""" service = _get_util_service(ctx) - password = service.generate_password(length) + kwargs = {} + if no_special: + kwargs["include_special_chars"] = False + if allowed_special_chars is not None: + kwargs["allowed_special_chars"] = allowed_special_chars + if special_mode is not None: + kwargs["special_mode"] = special_mode + if exclude_ambiguous: + kwargs["exclude_ambiguous"] = True + if min_uppercase is not None: + kwargs["min_uppercase"] = min_uppercase + if min_lowercase is not None: + kwargs["min_lowercase"] = min_lowercase + if min_digits is not None: + kwargs["min_digits"] = min_digits + if min_special is not None: + kwargs["min_special"] = min_special + + password = service.generate_password(length, **kwargs) typer.echo(password) diff --git a/src/seedpass/core/api.py b/src/seedpass/core/api.py index 04c5837..5fa4ae8 100644 --- a/src/seedpass/core/api.py +++ b/src/seedpass/core/api.py @@ -9,7 +9,8 @@ allow easy validation and documentation. from pathlib import Path from threading import Lock -from typing import List, Optional, Dict +from typing import List, Optional, Dict, Any +import dataclasses import json from pydantic import BaseModel @@ -283,9 +284,42 @@ class EntryService: length: int, username: str | None = None, url: 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: with self._lock: - idx = self._manager.entry_manager.add_entry(label, length, username, url) + kwargs: dict[str, Any] = {} + if include_special_chars is not None: + kwargs["include_special_chars"] = include_special_chars + if allowed_special_chars is not None: + kwargs["allowed_special_chars"] = allowed_special_chars + if special_mode is not None: + kwargs["special_mode"] = special_mode + if exclude_ambiguous is not None: + kwargs["exclude_ambiguous"] = exclude_ambiguous + if min_uppercase is not None: + kwargs["min_uppercase"] = min_uppercase + if min_lowercase is not None: + kwargs["min_lowercase"] = min_lowercase + if min_digits is not None: + kwargs["min_digits"] = min_digits + if min_special is not None: + kwargs["min_special"] = min_special + + idx = self._manager.entry_manager.add_entry( + label, + length, + username, + url, + **kwargs, + ) self._manager.start_background_vault_sync() return idx @@ -557,9 +591,50 @@ class UtilityService: self._manager = manager self._lock = Lock() - def generate_password(self, length: int) -> str: + def generate_password( + self, + length: int, + *, + 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, + ) -> str: with self._lock: - return self._manager.password_generator.generate_password(length) + pg = self._manager.password_generator + base_policy = getattr(pg, "policy", None) + overrides: dict[str, Any] = {} + if include_special_chars is not None: + overrides["include_special_chars"] = include_special_chars + if allowed_special_chars is not None: + overrides["allowed_special_chars"] = allowed_special_chars + if special_mode is not None: + overrides["special_mode"] = special_mode + if exclude_ambiguous is not None: + overrides["exclude_ambiguous"] = exclude_ambiguous + if min_uppercase is not None: + overrides["min_uppercase"] = int(min_uppercase) + if min_lowercase is not None: + overrides["min_lowercase"] = int(min_lowercase) + if min_digits is not None: + overrides["min_digits"] = int(min_digits) + if min_special is not None: + overrides["min_special"] = int(min_special) + + if base_policy is not None and overrides: + pg.policy = dataclasses.replace( + base_policy, + **{k: overrides[k] for k in overrides if hasattr(base_policy, k)}, + ) + try: + return pg.generate_password(length) + finally: + pg.policy = base_policy + return pg.generate_password(length) def verify_checksum(self) -> None: with self._lock: diff --git a/src/tests/test_cli_entry_add_commands.py b/src/tests/test_cli_entry_add_commands.py index 53d2345..ab230b4 100644 --- a/src/tests/test_cli_entry_add_commands.py +++ b/src/tests/test_cli_entry_add_commands.py @@ -22,9 +22,32 @@ runner = CliRunner() "user", "--url", "https://example.com", + "--no-special", + "--allowed-special-chars", + "!@", + "--special-mode", + "safe", + "--exclude-ambiguous", + "--min-uppercase", + "1", + "--min-lowercase", + "2", + "--min-digits", + "3", + "--min-special", + "4", ], ("Label", 16, "user", "https://example.com"), - {}, + { + "include_special_chars": False, + "allowed_special_chars": "!@", + "special_mode": "safe", + "exclude_ambiguous": True, + "min_uppercase": 1, + "min_lowercase": 2, + "min_digits": 3, + "min_special": 4, + }, "1", ), ( diff --git a/src/tests/test_typer_cli.py b/src/tests/test_typer_cli.py index c9d0dab..0587d9a 100644 --- a/src/tests/test_typer_cli.py +++ b/src/tests/test_typer_cli.py @@ -321,18 +321,54 @@ def test_nostr_sync(monkeypatch): def test_generate_password(monkeypatch): called = {} - def gen_pw(length): + def gen_pw(length, **kwargs): called["length"] = length + called["kwargs"] = kwargs return "secretpw" - pm = SimpleNamespace( - password_generator=SimpleNamespace(generate_password=gen_pw), - select_fingerprint=lambda fp: None, + monkeypatch.setattr( + cli, + "PasswordManager", + lambda: SimpleNamespace(select_fingerprint=lambda fp: None), + ) + monkeypatch.setattr( + cli, "UtilityService", lambda pm: SimpleNamespace(generate_password=gen_pw) + ) + result = runner.invoke( + app, + [ + "util", + "generate-password", + "--length", + "12", + "--no-special", + "--allowed-special-chars", + "!@", + "--special-mode", + "safe", + "--exclude-ambiguous", + "--min-uppercase", + "1", + "--min-lowercase", + "2", + "--min-digits", + "3", + "--min-special", + "4", + ], ) - monkeypatch.setattr(cli, "PasswordManager", lambda: pm) - result = runner.invoke(app, ["util", "generate-password", "--length", "12"]) assert result.exit_code == 0 assert called.get("length") == 12 + assert called.get("kwargs") == { + "include_special_chars": False, + "allowed_special_chars": "!@", + "special_mode": "safe", + "exclude_ambiguous": True, + "min_uppercase": 1, + "min_lowercase": 2, + "min_digits": 3, + "min_special": 4, + } assert "secretpw" in result.stdout @@ -370,8 +406,9 @@ def test_entry_list_passes_fingerprint(monkeypatch): def test_entry_add(monkeypatch): called = {} - def add_entry(label, length, username=None, url=None): + def add_entry(label, length, username=None, url=None, **kwargs): called["args"] = (label, length, username, url) + called["kwargs"] = kwargs return 2 pm = SimpleNamespace( @@ -392,11 +429,35 @@ def test_entry_add(monkeypatch): "bob", "--url", "ex.com", + "--no-special", + "--allowed-special-chars", + "!@", + "--special-mode", + "safe", + "--exclude-ambiguous", + "--min-uppercase", + "1", + "--min-lowercase", + "2", + "--min-digits", + "3", + "--min-special", + "4", ], ) assert result.exit_code == 0 assert "2" in result.stdout assert called["args"] == ("Example", 16, "bob", "ex.com") + assert called["kwargs"] == { + "include_special_chars": False, + "allowed_special_chars": "!@", + "special_mode": "safe", + "exclude_ambiguous": True, + "min_uppercase": 1, + "min_lowercase": 2, + "min_digits": 3, + "min_special": 4, + } def test_entry_modify(monkeypatch):