diff --git a/README.md b/README.md index eedb311b..ad2c6361 100644 --- a/README.md +++ b/README.md @@ -224,7 +224,6 @@ Follow these steps to set up SeedPass on your local machine. python -m pip install --require-hashes -r requirements.lock python -m pip install -e . ``` -// 🔧 merged conflicting changes from codex/locate-command-usage-issue-in-seedpass vs beta After reinstalling, run `which seedpass` on Linux/macOS or `where seedpass` on Windows to confirm the command resolves to your virtual environment's `seedpass` executable. #### Linux Clipboard Support @@ -322,7 +321,7 @@ seedpass util generate-password --length 20 --special-mode safe --exclude-ambigu # on an external drive. ``` -For additional command examples, see [docs/advanced_cli.md](docs/advanced_cli.md). Details on the REST API can be found in [docs/api_reference.md](docs/api_reference.md). +For additional command examples, see [docs/docs/content/01-getting-started/01-advanced_cli.md](docs/docs/content/01-getting-started/01-advanced_cli.md). Details on the REST API can be found in [docs/docs/content/01-getting-started/02-api_reference.md](docs/docs/content/01-getting-started/02-api_reference.md). ### Getting Started with the GUI @@ -436,14 +435,13 @@ If this command displays `usage: main.py` instead of the Typer help output, an o ```bash python -m pip install -e . ``` -// 🔧 merged conflicting changes from codex/locate-command-usage-issue-in-seedpass vs beta You can confirm which executable will run with: ```bash which seedpass # or 'where seedpass' on Windows ``` -For a full list of commands see [docs/advanced_cli.md](docs/advanced_cli.md). The REST API is described in [docs/api_reference.md](docs/api_reference.md). +For a full list of commands see [docs/docs/content/01-getting-started/01-advanced_cli.md](docs/docs/content/01-getting-started/01-advanced_cli.md). The REST API is described in [docs/docs/content/01-getting-started/02-api_reference.md](docs/docs/content/01-getting-started/02-api_reference.md). ### Agent Mode (Non-Interactive) diff --git a/docs/docs/content/index.md b/docs/docs/content/index.md index a7a8ec16..cc733ecf 100644 --- a/docs/docs/content/index.md +++ b/docs/docs/content/index.md @@ -245,8 +245,8 @@ seedpass list --filter totp # on an external drive. ``` -For additional command examples, see [docs/advanced_cli.md](docs/advanced_cli.md). -Details on the REST API can be found in [docs/api_reference.md](docs/api_reference.md). +For additional command examples, see [01-getting-started/01-advanced_cli.md](01-getting-started/01-advanced_cli.md). +Details on the REST API can be found in [01-getting-started/02-api_reference.md](01-getting-started/02-api_reference.md). ### Vault JSON Layout @@ -285,7 +285,7 @@ You can explore other CLI commands using: ```bash seedpass --help ``` -For a full list of commands see [docs/advanced_cli.md](docs/advanced_cli.md). The REST API is described in [docs/api_reference.md](docs/api_reference.md). +For a full list of commands see [01-getting-started/01-advanced_cli.md](01-getting-started/01-advanced_cli.md). The REST API is described in [01-getting-started/02-api_reference.md](01-getting-started/02-api_reference.md). ### Running the Application diff --git a/src/seedpass/api.py b/src/seedpass/api.py index 4c496885..82c25dac 100644 --- a/src/seedpass/api.py +++ b/src/seedpass/api.py @@ -7,23 +7,18 @@ import tempfile from pathlib import Path import secrets import queue +import time +from collections import defaultdict, deque from typing import Any, List, Optional import logging from fastapi import FastAPI, Header, HTTPException, Request, Response -from fastapi.concurrency import run_in_threadpool import asyncio import sys -from fastapi.middleware.cors import CORSMiddleware import bcrypt -from slowapi import Limiter, _rate_limit_exceeded_handler -from slowapi.errors import RateLimitExceeded -from slowapi.util import get_remote_address -from slowapi.middleware import SlowAPIMiddleware - from seedpass.core.manager import PasswordManager from seedpass.core.entry_types import EntryType from seedpass.core.api import UtilityService @@ -31,13 +26,26 @@ from seedpass.core.api import UtilityService _RATE_LIMIT = int(os.getenv("SEEDPASS_RATE_LIMIT", "100")) _RATE_WINDOW = int(os.getenv("SEEDPASS_RATE_WINDOW", "60")) _RATE_LIMIT_STR = f"{_RATE_LIMIT}/{_RATE_WINDOW} seconds" +_MAX_IMPORT_BYTES = int(os.getenv("SEEDPASS_MAX_IMPORT_BYTES", str(10 * 1024 * 1024))) -limiter = Limiter(key_func=get_remote_address, default_limits=[_RATE_LIMIT_STR]) app = FastAPI() logger = logging.getLogger(__name__) +@app.middleware("http") +async def dynamic_cors_headers(request: Request, call_next): + response = await call_next(request) + origins = { + o.strip() for o in os.getenv("SEEDPASS_CORS_ORIGINS", "").split(",") if o.strip() + } + request_origin = request.headers.get("origin") + if request_origin and request_origin in origins: + response.headers["access-control-allow-origin"] = request_origin + response.headers["vary"] = "Origin" + return response + + def _get_pm(request: Request) -> PasswordManager: pm = getattr(request.app.state, "pm", None) assert pm is not None @@ -47,10 +55,31 @@ def _get_pm(request: Request) -> PasswordManager: def _check_token(request: Request, auth: str | None) -> None: if auth is None or not auth.startswith("Bearer "): raise HTTPException(status_code=401, detail="Unauthorized") - token = auth.split(" ", 1)[1].encode() + raw_token = auth.split(" ", 1)[1] + token = raw_token.encode() token_hash = getattr(request.app.state, "token_hash", b"") if not token_hash or not bcrypt.checkpw(token, token_hash): raise HTTPException(status_code=401, detail="Unauthorized") + _enforce_rate_limit(request, raw_token) + + +def _enforce_rate_limit(request: Request, raw_token: str) -> None: + now = time.monotonic() + client = request.client.host if request.client else "unknown" + key = f"{client}:{raw_token}" + buckets = getattr(request.app.state, "rate_limit_buckets", None) + if buckets is None: + buckets = defaultdict(deque) + request.app.state.rate_limit_buckets = buckets + bucket = buckets[key] + while bucket and (now - bucket[0]) > _RATE_WINDOW: + bucket.popleft() + if len(bucket) >= _RATE_LIMIT: + raise HTTPException( + status_code=429, + detail=f"Rate limit exceeded ({_RATE_LIMIT_STR})", + ) + bucket.append(now) def _reload_relays(request: Request, relays: list[str]) -> None: @@ -82,22 +111,7 @@ def start_server(fingerprint: str | None = None) -> str: app.state.pm = pm raw_token = secrets.token_urlsafe(32) app.state.token_hash = bcrypt.hashpw(raw_token.encode(), bcrypt.gensalt()) - if not getattr(app.state, "limiter", None): - app.state.limiter = limiter - app.add_exception_handler(RateLimitExceeded, _rate_limit_exceeded_handler) - app.add_middleware(SlowAPIMiddleware) - origins = [ - o.strip() - for o in os.getenv("SEEDPASS_CORS_ORIGINS", "").split(",") - if o.strip() - ] - if origins and app.middleware_stack is None: - app.add_middleware( - CORSMiddleware, - allow_origins=origins, - allow_methods=["*"], - allow_headers=["*"], - ) + app.state.rate_limit_buckets = defaultdict(deque) return raw_token @@ -126,7 +140,7 @@ async def search_entry( ) -> List[Any]: _check_token(request, authorization) pm = _get_pm(request) - results = await run_in_threadpool(pm.entry_manager.search_entries, query) + results = pm.entry_manager.search_entries(query) return [ { "id": idx, @@ -150,7 +164,7 @@ async def get_entry( _check_token(request, authorization) _require_password(request, password) pm = _get_pm(request) - entry = await run_in_threadpool(pm.entry_manager.retrieve_entry, entry_id) + entry = pm.entry_manager.retrieve_entry(entry_id) if entry is None: raise HTTPException(status_code=404, detail="Not found") return entry @@ -186,8 +200,7 @@ async def create_entry( ] kwargs = {k: entry.get(k) for k in policy_keys if entry.get(k) is not None} - index = await run_in_threadpool( - pm.entry_manager.add_entry, + index = pm.entry_manager.add_entry( entry.get("label"), int(entry.get("length", 12)), entry.get("username"), @@ -197,10 +210,9 @@ async def create_entry( return {"id": index} if etype == "totp": - index = await run_in_threadpool(pm.entry_manager.get_next_index) + index = pm.entry_manager.get_next_index() - uri = await run_in_threadpool( - pm.entry_manager.add_totp, + uri = pm.entry_manager.add_totp( entry.get("label"), pm.KEY_TOTP_DET if entry.get("deterministic", False) else None, secret=entry.get("secret"), @@ -214,8 +226,7 @@ async def create_entry( return {"id": index, "uri": uri} if etype == "ssh": - index = await run_in_threadpool( - pm.entry_manager.add_ssh_key, + index = pm.entry_manager.add_ssh_key( entry.get("label"), pm.parent_seed, index=entry.get("index"), @@ -225,8 +236,7 @@ async def create_entry( return {"id": index} if etype == "pgp": - index = await run_in_threadpool( - pm.entry_manager.add_pgp_key, + index = pm.entry_manager.add_pgp_key( entry.get("label"), pm.parent_seed, index=entry.get("index"), @@ -238,8 +248,7 @@ async def create_entry( return {"id": index} if etype == "nostr": - index = await run_in_threadpool( - pm.entry_manager.add_nostr_key, + index = pm.entry_manager.add_nostr_key( entry.get("label"), pm.parent_seed, index=entry.get("index"), @@ -249,8 +258,7 @@ async def create_entry( return {"id": index} if etype == "key_value": - index = await run_in_threadpool( - pm.entry_manager.add_key_value, + index = pm.entry_manager.add_key_value( entry.get("label"), entry.get("key"), entry.get("value"), @@ -264,8 +272,7 @@ async def create_entry( if etype == "seed" else pm.entry_manager.add_managed_account ) - index = await run_in_threadpool( - func, + index = func( entry.get("label"), pm.parent_seed, index=entry.get("index"), @@ -638,7 +645,12 @@ async def import_vault( if file is None: raise HTTPException(status_code=400, detail="Missing file") - data = await file.read() + data = await file.read(_MAX_IMPORT_BYTES + 1) + if len(data) > _MAX_IMPORT_BYTES: + raise HTTPException( + status_code=413, + detail=f"Uploaded file exceeds max size of {_MAX_IMPORT_BYTES} bytes", + ) with tempfile.NamedTemporaryFile(delete=False) as tmp: tmp.write(data) tmp_path = Path(tmp.name) diff --git a/src/seedpass/cli/agent.py b/src/seedpass/cli/agent.py index 403b5d6c..ad7b2ce4 100644 --- a/src/seedpass/cli/agent.py +++ b/src/seedpass/cli/agent.py @@ -47,19 +47,45 @@ def _policy_path() -> Path: return APP_DIR / "agent_policy.json" -def _load_policy() -> dict: +def _deny_all_policy() -> dict: + return { + "allow_kinds": [], + "deny_private_reveal": list(ALL_ENTRY_TYPES), + "allow_export_import": False, + } + + +def _load_policy(*, strict: bool = False) -> dict: path = _policy_path() if not path.exists(): return dict(DEFAULT_POLICY) try: data = json.loads(path.read_text(encoding="utf-8")) - except Exception: - return dict(DEFAULT_POLICY) + except Exception as exc: + if strict: + raise ValueError("agent policy file is not valid JSON") from exc + return _deny_all_policy() + + if not isinstance(data, dict): + if strict: + raise ValueError("agent policy file must be a JSON object") + return _deny_all_policy() + policy = dict(DEFAULT_POLICY) if isinstance(data.get("allow_kinds"), list): - policy["allow_kinds"] = [str(v) for v in data["allow_kinds"]] + allow_kinds = [str(v) for v in data["allow_kinds"]] + invalid = [v for v in allow_kinds if v not in ALL_ENTRY_TYPES] + if invalid and strict: + raise ValueError(f"agent policy has invalid allow_kinds entries: {invalid}") + policy["allow_kinds"] = [v for v in allow_kinds if v in ALL_ENTRY_TYPES] if isinstance(data.get("deny_private_reveal"), list): - policy["deny_private_reveal"] = [str(v) for v in data["deny_private_reveal"]] + deny_private = [str(v) for v in data["deny_private_reveal"]] + invalid = [v for v in deny_private if v not in ALL_ENTRY_TYPES] + if invalid and strict: + raise ValueError( + f"agent policy has invalid deny_private_reveal entries: {invalid}" + ) + policy["deny_private_reveal"] = [v for v in deny_private if v in ALL_ENTRY_TYPES] if isinstance(data.get("allow_export_import"), bool): policy["allow_export_import"] = data["allow_export_import"] return policy @@ -187,7 +213,11 @@ def agent_init( @app.command("policy-show") def agent_policy_show() -> None: """Show the active agent policy.""" - typer.echo(json.dumps(_load_policy(), indent=2)) + try: + policy = _load_policy(strict=True) + except ValueError as exc: + raise typer.BadParameter(str(exc)) + typer.echo(json.dumps(policy, indent=2)) @app.command("policy-set") @@ -211,7 +241,7 @@ def agent_policy_set( ), ) -> None: """Set policy flags used by agent commands.""" - policy = _load_policy() + policy = _load_policy(strict=False) if allow_kind: policy["allow_kinds"] = list(allow_kind) if deny_private_kind: @@ -240,7 +270,20 @@ def agent_get( if not fingerprint: raise typer.BadParameter("Specify target profile with --fingerprint.") password = _password_from_env(password_env) - policy = _load_policy() + try: + policy = _load_policy(strict=True) + except ValueError as exc: + typer.echo( + json.dumps( + { + "status": "denied", + "reason": "invalid_policy", + "detail": str(exc), + }, + indent=2, + ) + ) + raise typer.Exit(1) pm = PasswordManager(fingerprint=fingerprint, password=password) service = EntryService(pm) diff --git a/src/tests/test_api_new_endpoints.py b/src/tests/test_api_new_endpoints.py index 674737c0..6715d353 100644 --- a/src/tests/test_api_new_endpoints.py +++ b/src/tests/test_api_new_endpoints.py @@ -284,6 +284,32 @@ async def test_vault_import_via_upload(client, tmp_path): assert called.get("sync") is True +@pytest.mark.anyio +async def test_vault_import_upload_too_large(client): + cl, token = client + called = {} + api.app.state.pm.handle_import_database = lambda path: called.setdefault( + "import_called", True + ) + api.app.state.pm.sync_vault = lambda: called.setdefault("sync_called", True) + + old_limit = api._MAX_IMPORT_BYTES + api._MAX_IMPORT_BYTES = 8 + try: + headers = {"Authorization": f"Bearer {token}"} + res = await cl.post( + "/api/v1/vault/import", + files={"file": ("big.json.enc", b"123456789")}, + headers=headers, + ) + finally: + api._MAX_IMPORT_BYTES = old_limit + + assert res.status_code == 413 + assert "exceeds max size" in res.json()["detail"] + assert called == {} + + @pytest.mark.anyio async def test_vault_import_invalid_extension(client): cl, token = client diff --git a/src/tests/test_cli_agent_mode.py b/src/tests/test_cli_agent_mode.py index 30d8448d..294a0f64 100644 --- a/src/tests/test_cli_agent_mode.py +++ b/src/tests/test_cli_agent_mode.py @@ -67,6 +67,23 @@ def test_agent_init_creates_profile(monkeypatch, tmp_path): assert (profile_dir / "parent_seed.enc").exists() +def test_agent_init_requires_password_env(monkeypatch, tmp_path): + monkeypatch.setattr(agent_cli, "APP_DIR", tmp_path) + monkeypatch.delenv("SEEDPASS_PASSWORD", raising=False) + result = runner.invoke( + app, + [ + "agent", + "init", + "--seed", + "abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon about", + ], + ) + assert result.exit_code == 2 + combined = f"{result.stdout}{result.stderr}" + assert "Missing password env var" in combined + + def test_agent_get_requires_fingerprint(monkeypatch): monkeypatch.setenv("SEEDPASS_PASSWORD", "pw") result = runner.invoke(app, ["agent", "get", "example"]) @@ -100,7 +117,7 @@ def test_agent_get_password_json(monkeypatch): monkeypatch.setattr( agent_cli, "_load_policy", - lambda: {"allow_kinds": ["password"], "deny_private_reveal": []}, + lambda **kwargs: {"allow_kinds": ["password"], "deny_private_reveal": []}, ) result = runner.invoke(app, ["--fingerprint", "ABC123", "agent", "get", "example"]) @@ -136,7 +153,7 @@ def test_agent_get_denied_by_policy(monkeypatch): monkeypatch.setattr( agent_cli, "_load_policy", - lambda: { + lambda **kwargs: { "allow_kinds": ["password", "totp", "key_value"], "deny_private_reveal": ["seed"], }, @@ -147,3 +164,127 @@ def test_agent_get_denied_by_policy(monkeypatch): payload = json.loads(result.stdout) assert payload["status"] == "denied" assert payload["reason"] in {"kind_not_allowed", "private_kind_blocked"} + + +def test_agent_get_ambiguous_returns_error_payload(monkeypatch): + monkeypatch.setenv("SEEDPASS_PASSWORD", "pw") + monkeypatch.setattr( + agent_cli, + "PasswordManager", + lambda fingerprint, password: SimpleNamespace( + fingerprint=fingerprint, password=password + ), + ) + + class DummyEntryService: + def __init__(self, _pm): + pass + + def search_entries(self, _q): + return [ + (2, "Example1", "", "", False, EntryType.PASSWORD), + (3, "Example2", "", "", False, EntryType.PASSWORD), + ] + + monkeypatch.setattr(agent_cli, "EntryService", DummyEntryService) + monkeypatch.setattr( + agent_cli, + "_load_policy", + lambda **kwargs: {"allow_kinds": ["password"], "deny_private_reveal": []}, + ) + + result = runner.invoke(app, ["--fingerprint", "ABC123", "agent", "get", "example"]) + assert result.exit_code == 1 + payload = json.loads(result.stdout) + assert payload["status"] == "error" + assert payload["reason"] == "ambiguous_or_missing" + assert payload["match_count"] == 2 + + +def test_agent_get_key_value_json(monkeypatch): + monkeypatch.setenv("SEEDPASS_PASSWORD", "pw") + monkeypatch.setattr( + agent_cli, + "PasswordManager", + lambda fingerprint, password: SimpleNamespace( + fingerprint=fingerprint, password=password + ), + ) + + class DummyEntryService: + def __init__(self, _pm): + pass + + def search_entries(self, _q): + return [(2, "Key", "", "", False, EntryType.KEY_VALUE)] + + def retrieve_entry(self, _i): + return {"kind": EntryType.KEY_VALUE.value, "label": "Key", "value": "v123"} + + monkeypatch.setattr(agent_cli, "EntryService", DummyEntryService) + monkeypatch.setattr( + agent_cli, + "_load_policy", + lambda **kwargs: {"allow_kinds": ["key_value"], "deny_private_reveal": []}, + ) + + result = runner.invoke(app, ["--fingerprint", "ABC123", "agent", "get", "key"]) + assert result.exit_code == 0 + payload = json.loads(result.stdout) + assert payload["status"] == "ok" + assert payload["kind"] == "key_value" + assert payload["secret"] == "v123" + + +def test_agent_get_unsupported_kind_payload(monkeypatch): + monkeypatch.setenv("SEEDPASS_PASSWORD", "pw") + monkeypatch.setattr( + agent_cli, + "PasswordManager", + lambda fingerprint, password: SimpleNamespace( + fingerprint=fingerprint, password=password + ), + ) + + class DummyEntryService: + def __init__(self, _pm): + pass + + def search_entries(self, _q): + return [(4, "SSH", "", "", False, EntryType.SSH)] + + def retrieve_entry(self, _i): + return {"kind": EntryType.SSH.value, "label": "SSH"} + + monkeypatch.setattr(agent_cli, "EntryService", DummyEntryService) + monkeypatch.setattr( + agent_cli, + "_load_policy", + lambda **kwargs: {"allow_kinds": ["ssh"], "deny_private_reveal": []}, + ) + + result = runner.invoke(app, ["--fingerprint", "ABC123", "agent", "get", "ssh"]) + assert result.exit_code == 1 + payload = json.loads(result.stdout) + assert payload["status"] == "error" + assert payload["reason"] == "unsupported_kind_for_agent_get" + + +def test_agent_policy_show_invalid_json(monkeypatch, tmp_path): + monkeypatch.setattr(agent_cli, "APP_DIR", tmp_path) + (tmp_path / "agent_policy.json").write_text("{", encoding="utf-8") + result = runner.invoke(app, ["agent", "policy-show"]) + assert result.exit_code == 2 + combined = f"{result.stdout}{result.stderr}" + assert "not valid JSON" in combined + + +def test_agent_get_denied_when_policy_invalid(monkeypatch, tmp_path): + monkeypatch.setattr(agent_cli, "APP_DIR", tmp_path) + (tmp_path / "agent_policy.json").write_text("{", encoding="utf-8") + monkeypatch.setenv("SEEDPASS_PASSWORD", "pw") + result = runner.invoke(app, ["--fingerprint", "ABC123", "agent", "get", "example"]) + assert result.exit_code == 1 + payload = json.loads(result.stdout) + assert payload["status"] == "denied" + assert payload["reason"] == "invalid_policy"