diff --git a/src/seedpass/api.py b/src/seedpass/api.py index a0a729d..095427f 100644 --- a/src/seedpass/api.py +++ b/src/seedpass/api.py @@ -16,6 +16,7 @@ from fastapi.middleware.cors import CORSMiddleware from seedpass.core.manager import PasswordManager from seedpass.core.entry_types import EntryType +from seedpass.core.api import UtilityService app = FastAPI() @@ -117,11 +118,23 @@ def create_entry( etype = (entry.get("type") or entry.get("kind") or "password").lower() if etype == "password": + policy_keys = [ + "include_special_chars", + "allowed_special_chars", + "special_mode", + "exclude_ambiguous", + "min_uppercase", + "min_lowercase", + "min_digits", + "min_special", + ] + kwargs = {k: entry.get(k) for k in policy_keys if entry.get(k) is not None} index = _pm.entry_manager.add_entry( entry.get("label"), int(entry.get("length", 12)), entry.get("username"), entry.get("url"), + **kwargs, ) return {"id": index} @@ -566,6 +579,30 @@ def change_password( return {"status": "ok"} +@app.post("/api/v1/password") +def generate_password( + data: dict, authorization: str | None = Header(None) +) -> dict[str, str]: + """Generate a password using optional policy overrides.""" + _check_token(authorization) + assert _pm is not None + length = int(data.get("length", 12)) + policy_keys = [ + "include_special_chars", + "allowed_special_chars", + "special_mode", + "exclude_ambiguous", + "min_uppercase", + "min_lowercase", + "min_digits", + "min_special", + ] + kwargs = {k: data.get(k) for k in policy_keys if data.get(k) is not None} + util = UtilityService(_pm) + password = util.generate_password(length, **kwargs) + return {"password": password} + + @app.post("/api/v1/vault/lock") def lock_vault(authorization: str | None = Header(None)) -> dict[str, str]: """Lock the vault and clear sensitive data from memory.""" diff --git a/src/seedpass/core/api.py b/src/seedpass/core/api.py index 5fa4ae8..2479896 100644 --- a/src/seedpass/core/api.py +++ b/src/seedpass/core/api.py @@ -84,6 +84,34 @@ class SyncResponse(BaseModel): delta_ids: List[str] = [] +class PasswordPolicyOptions(BaseModel): + """Optional password policy overrides.""" + + 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 + + +class AddPasswordEntryRequest(PasswordPolicyOptions): + label: str + length: int + username: str | None = None + url: str | None = None + + +class GeneratePasswordRequest(PasswordPolicyOptions): + length: int + + +class GeneratePasswordResponse(BaseModel): + password: str + + class VaultService: """Thread-safe wrapper around vault operations.""" diff --git a/src/tests/test_api_new_endpoints.py b/src/tests/test_api_new_endpoints.py index 337a724..853a644 100644 --- a/src/tests/test_api_new_endpoints.py +++ b/src/tests/test_api_new_endpoints.py @@ -5,6 +5,8 @@ import pytest from seedpass import api from test_api import client from helpers import dummy_nostr_client +import string +from seedpass.core.password_generation import PasswordGenerator, PasswordPolicy from nostr.client import NostrClient, DEFAULT_RELAYS @@ -401,3 +403,55 @@ def test_relay_management_endpoints(client, dummy_nostr_client, monkeypatch): assert res.status_code == 200 assert called.get("init") is True assert api._pm.nostr_client.relays == list(DEFAULT_RELAYS) + + +def test_generate_password_no_special_chars(client): + cl, token = client + + 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(range(bytes_len)) + + api._pm.password_generator = PasswordGenerator(DummyEnc(), "seed", DummyBIP85()) + api._pm.parent_seed = "seed" + + headers = {"Authorization": f"Bearer {token}"} + res = cl.post( + "/api/v1/password", + json={"length": 16, "include_special_chars": False}, + headers=headers, + ) + assert res.status_code == 200 + pw = res.json()["password"] + assert not any(c in string.punctuation for c in pw) + + +def test_generate_password_allowed_chars(client): + cl, token = client + + 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)) + + api._pm.password_generator = PasswordGenerator(DummyEnc(), "seed", DummyBIP85()) + api._pm.parent_seed = "seed" + + headers = {"Authorization": f"Bearer {token}"} + allowed = "@$" + res = cl.post( + "/api/v1/password", + json={"length": 16, "allowed_special_chars": allowed}, + headers=headers, + ) + assert res.status_code == 200 + pw = res.json()["password"] + specials = [c for c in pw if c in string.punctuation] + assert specials and all(c in allowed for c in specials)