feat: extend password options

This commit is contained in:
thePR0M3TH3AN
2025-07-30 20:22:53 -04:00
parent 3f169747d1
commit b57e19b657
4 changed files with 255 additions and 15 deletions

View File

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

View File

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

View File

@@ -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",
),
(

View File

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