diff --git a/docs/docs/content/01-getting-started/01-advanced_cli.md b/docs/docs/content/01-getting-started/01-advanced_cli.md index 13336a3..ae971f7 100644 --- a/docs/docs/content/01-getting-started/01-advanced_cli.md +++ b/docs/docs/content/01-getting-started/01-advanced_cli.md @@ -127,7 +127,7 @@ Run or stop the local HTTP API. | Action | Command | Examples | | :--- | :--- | :--- | | Start the API | `api start` | `seedpass api start --host 0.0.0.0 --port 8000` | -| Stop the API | `api stop` | `seedpass api stop` | +| Stop the API | `api stop --token TOKEN` | `seedpass api stop --token ` | --- @@ -214,7 +214,7 @@ Set the `SEEDPASS_CORS_ORIGINS` environment variable to a comma‑separated list SEEDPASS_CORS_ORIGINS=http://localhost:3000 seedpass api start ``` -Shut down the server with `seedpass api stop`. +Shut down the server with `seedpass api stop --token `. --- diff --git a/src/seedpass/api.py b/src/seedpass/api.py index 384f002..1aaef77 100644 --- a/src/seedpass/api.py +++ b/src/seedpass/api.py @@ -9,8 +9,6 @@ import secrets import queue from typing import Any, List, Optional -from datetime import datetime, timedelta, timezone -import jwt import logging from fastapi import FastAPI, Header, HTTPException, Request, Response @@ -18,8 +16,8 @@ from fastapi.concurrency import run_in_threadpool import asyncio import sys from fastapi.middleware.cors import CORSMiddleware -import hashlib -import hmac + +import bcrypt from slowapi import Limiter, _rate_limit_exceeded_handler from slowapi.errors import RateLimitExceeded @@ -50,16 +48,9 @@ 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] - jwt_secret = getattr(request.app.state, "jwt_secret", "") - token_hash = getattr(request.app.state, "token_hash", "") - try: - jwt.decode(token, jwt_secret, algorithms=["HS256"]) - except jwt.ExpiredSignatureError: - raise HTTPException(status_code=401, detail="Token expired") - except jwt.InvalidTokenError: - raise HTTPException(status_code=401, detail="Unauthorized") - if not hmac.compare_digest(hashlib.sha256(token.encode()).hexdigest(), token_hash): + token = auth.split(" ", 1)[1].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") @@ -78,7 +69,7 @@ def _reload_relays(request: Request, relays: list[str]) -> None: def start_server(fingerprint: str | None = None) -> str: - """Initialize global state and return a short-lived JWT token. + """Initialize global state and return a random API token. Parameters ---------- @@ -90,10 +81,8 @@ def start_server(fingerprint: str | None = None) -> str: else: pm = PasswordManager(fingerprint=fingerprint) app.state.pm = pm - app.state.jwt_secret = secrets.token_urlsafe(32) - payload = {"exp": datetime.now(timezone.utc) + timedelta(minutes=5)} - raw_token = jwt.encode(payload, app.state.jwt_secret, algorithm="HS256") - app.state.token_hash = hashlib.sha256(raw_token.encode()).hexdigest() + 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) diff --git a/src/seedpass/cli/api.py b/src/seedpass/cli/api.py index 8c8b10a..a4d33d0 100644 --- a/src/seedpass/cli/api.py +++ b/src/seedpass/cli/api.py @@ -13,19 +13,25 @@ app = typer.Typer(help="Run the API server") def api_start(ctx: typer.Context, host: str = "127.0.0.1", port: int = 8000) -> None: """Start the SeedPass API server.""" token = api_module.start_server(ctx.obj.get("fingerprint")) - typer.echo(f"API token: {token}") + typer.echo( + f"API token: {token}\nWARNING: Store this token securely; it cannot be recovered." + ) uvicorn.run(api_module.app, host=host, port=port) @app.command("stop") -def api_stop(ctx: typer.Context, host: str = "127.0.0.1", port: int = 8000) -> None: +def api_stop( + token: str = typer.Option(..., help="API token"), + host: str = "127.0.0.1", + port: int = 8000, +) -> None: """Stop the SeedPass API server.""" import requests try: requests.post( f"http://{host}:{port}/api/v1/shutdown", - headers={"Authorization": f"Bearer {api_module.app.state.token_hash}"}, + headers={"Authorization": f"Bearer {token}"}, timeout=2, ) except Exception as exc: # pragma: no cover - best effort diff --git a/src/tests/test_api.py b/src/tests/test_api.py index 5608361..ec0fabc 100644 --- a/src/tests/test_api.py +++ b/src/tests/test_api.py @@ -4,7 +4,7 @@ import sys import pytest from httpx import ASGITransport, AsyncClient -import hashlib +import bcrypt sys.path.append(str(Path(__file__).resolve().parents[1])) @@ -54,7 +54,7 @@ async def client(monkeypatch): async def test_token_hashed(client): _, token = client assert api.app.state.token_hash != token - assert api.app.state.token_hash == hashlib.sha256(token.encode()).hexdigest() + assert bcrypt.checkpw(token.encode(), api.app.state.token_hash) @pytest.mark.anyio