feat: add short-lived JWT auth and secure endpoints

This commit is contained in:
thePR0M3TH3AN
2025-08-02 21:48:52 -04:00
parent 8c9fe07609
commit 186e39cc91
6 changed files with 104 additions and 16 deletions

View File

@@ -717,6 +717,30 @@ You can also launch the GUI directly with `seedpass gui` or `seedpass-gui`.
- **Offline Mode:** When enabled, SeedPass skips all Nostr operations so your vault stays local until syncing is turned back on.
- **Quick Unlock:** Stores a hashed copy of your password in the encrypted config so you only need to enter it once per session. Avoid this on shared computers.
### Secure Deployment
Always deploy SeedPass behind HTTPS. Place a TLSterminating reverse proxy such as Nginx in front of the FastAPI server or configure Uvicorn with certificate files. Example Nginx snippet:
```
server {
listen 443 ssl;
ssl_certificate /etc/letsencrypt/live/example.com/fullchain.pem;
ssl_certificate_key /etc/letsencrypt/live/example.com/privkey.pem;
location / {
proxy_pass http://127.0.0.1:8000;
proxy_set_header Host $host;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
}
}
```
For local testing, Uvicorn can run with TLS directly:
```
uvicorn seedpass.api:app --ssl-certfile=cert.pem --ssl-keyfile=key.pem
```
## Contributing
Contributions are welcome! If you have suggestions for improvements, bug fixes, or new features, please follow these steps:

View File

@@ -7,14 +7,14 @@ This guide covers how to start the SeedPass API, authenticate requests, and inte
## Starting the API
Run `seedpass api start` from your terminal. The command prints a onetime token used for authentication:
Run `seedpass api start` from your terminal. The command prints a shortlived JWT token used for authentication:
```bash
$ seedpass api start
API token: abcdef1234567890
API token: eyJ0eXAiOiJKV1QiLCJhbGciOiJIUzI1NiJ9...
```
Keep this token secret. Every request must include it in the `Authorization` header using the `Bearer` scheme.
Keep this token secret and avoid logging it. Tokens expire after a few minutes and every request must include one in the `Authorization` header using the `Bearer` scheme.
## Endpoints
@@ -35,13 +35,13 @@ Keep this token secret. Every request must include it in the `Authorization` hea
- `GET /api/v1/totp` Return current TOTP codes and remaining time.
- `GET /api/v1/stats` Return statistics about the active seed profile.
- `GET /api/v1/notifications` Retrieve and clear queued notifications. Messages appear in the persistent notification box but remain queued until fetched.
- `GET /api/v1/parent-seed` Reveal the parent seed or save it with `?file=`.
- `GET /api/v1/parent-seed` Reveal the parent seed or save it with `?file=`. Requires an additional `X-SeedPass-Password` header.
- `GET /api/v1/nostr/pubkey` Fetch the Nostr public key for the active seed.
- `POST /api/v1/checksum/verify` Verify the checksum of the running script.
- `POST /api/v1/checksum/update` Update the stored script checksum.
- `POST /api/v1/change-password` Change the master password for the active profile.
- `POST /api/v1/vault/import` Import a vault backup from a file or path.
- `POST /api/v1/vault/export` Export the vault and download the encrypted file.
- `POST /api/v1/vault/export` Export the vault and download the encrypted file. Requires an additional `X-SeedPass-Password` header.
- `POST /api/v1/vault/backup-parent-seed` Save an encrypted backup of the parent seed.
- `POST /api/v1/vault/lock` Lock the vault and clear sensitive data from memory.
- `GET /api/v1/relays` List configured Nostr relays.
@@ -52,13 +52,37 @@ Keep this token secret. Every request must include it in the `Authorization` hea
**Security Warning:** Accessing `/api/v1/parent-seed` exposes your master seed in plain text. Use it only from a trusted environment.
## Secure Deployment
Always run the API behind HTTPS. Use a reverse proxy such as Nginx or Caddy to terminate TLS and forward requests to SeedPass. Example Nginx configuration:
```
server {
listen 443 ssl;
ssl_certificate /etc/letsencrypt/live/example.com/fullchain.pem;
ssl_certificate_key /etc/letsencrypt/live/example.com/privkey.pem;
location / {
proxy_pass http://127.0.0.1:8000;
proxy_set_header Host $host;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
}
}
```
For local testing, Uvicorn can serve TLS directly:
```
uvicorn seedpass.api:app --ssl-certfile=cert.pem --ssl-keyfile=key.pem
```
## Example Requests
Send requests with the token in the header:
```bash
curl -H "Authorization: Bearer <token>" \
"http://127.0.0.1:8000/api/v1/entry?query=email"
"https://127.0.0.1:8000/api/v1/entry?query=email"
```
### Creating an Entry
@@ -149,8 +173,9 @@ curl -X POST http://127.0.0.1:8000/api/v1/fingerprint/select \
Download an encrypted vault backup via `POST /api/v1/vault/export`:
```bash
curl -X POST http://127.0.0.1:8000/api/v1/vault/export \
curl -X POST https://127.0.0.1:8000/api/v1/vault/export \
-H "Authorization: Bearer <token>" \
-H "X-SeedPass-Password: <master-password>" \
-o backup.json
```

View File

@@ -31,6 +31,7 @@ starlette>=0.47.2
httpx>=0.28.1
requests>=2.32
python-multipart
PyJWT
orjson
argon2-cffi
toga-core>=0.5.2

View File

@@ -9,6 +9,9 @@ import secrets
import queue
from typing import Any, List, Optional
from datetime import datetime, timedelta, timezone
import jwt
from fastapi import FastAPI, Header, HTTPException, Request, Response
import asyncio
import sys
@@ -23,10 +26,18 @@ app = FastAPI()
_pm: Optional[PasswordManager] = None
_token: str = ""
_jwt_secret: str = ""
def _check_token(auth: str | None) -> None:
if auth != f"Bearer {_token}":
if auth is None or not auth.startswith("Bearer "):
raise HTTPException(status_code=401, detail="Unauthorized")
token = auth.split(" ", 1)[1]
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")
@@ -45,20 +56,21 @@ def _reload_relays(relays: list[str]) -> None:
def start_server(fingerprint: str | None = None) -> str:
"""Initialize global state and return the API token.
"""Initialize global state and return a short-lived JWT token.
Parameters
----------
fingerprint:
Optional seed profile fingerprint to select before starting the server.
"""
global _pm, _token
global _pm, _token, _jwt_secret
if fingerprint is None:
_pm = PasswordManager()
else:
_pm = PasswordManager(fingerprint=fingerprint)
_token = secrets.token_urlsafe(16)
print(f"API token: {_token}")
_jwt_secret = secrets.token_urlsafe(32)
payload = {"exp": datetime.now(timezone.utc) + timedelta(minutes=5)}
_token = jwt.encode(payload, _jwt_secret, algorithm="HS256")
origins = [
o.strip()
for o in os.getenv("SEEDPASS_CORS_ORIGINS", "").split(",")
@@ -74,6 +86,12 @@ def start_server(fingerprint: str | None = None) -> str:
return _token
def _require_password(password: str | None) -> None:
assert _pm is not None
if password is None or not _pm.verify_password(password):
raise HTTPException(status_code=401, detail="Invalid password")
@app.get("/api/v1/entry")
def search_entry(query: str, authorization: str | None = Header(None)) -> List[Any]:
_check_token(authorization)
@@ -414,10 +432,13 @@ def get_notifications(authorization: str | None = Header(None)) -> List[dict]:
@app.get("/api/v1/parent-seed")
def get_parent_seed(
authorization: str | None = Header(None), file: str | None = None
authorization: str | None = Header(None),
file: str | None = None,
password: str | None = Header(None, alias="X-SeedPass-Password"),
) -> dict:
"""Return the parent seed or save it as an encrypted backup."""
_check_token(authorization)
_require_password(password)
assert _pm is not None
if file:
path = Path(file)
@@ -511,9 +532,13 @@ def update_checksum(authorization: str | None = Header(None)) -> dict[str, str]:
@app.post("/api/v1/vault/export")
def export_vault(authorization: str | None = Header(None)):
def export_vault(
authorization: str | None = Header(None),
password: str | None = Header(None, alias="X-SeedPass-Password"),
):
"""Export the vault and return the encrypted file."""
_check_token(authorization)
_require_password(password)
assert _pm is not None
path = _pm.handle_export_database()
if path is None:

View File

@@ -39,6 +39,7 @@ def client(monkeypatch):
nostr_client=SimpleNamespace(
key_manager=SimpleNamespace(get_npub=lambda: "np")
),
verify_password=lambda pw: True,
)
monkeypatch.setattr(api, "PasswordManager", lambda: dummy)
monkeypatch.setenv("SEEDPASS_CORS_ORIGINS", "http://example.com")

View File

@@ -162,7 +162,10 @@ def test_parent_seed_endpoint(client, tmp_path):
api._pm.encryption_manager = SimpleNamespace(
encrypt_and_save_file=lambda data, path: called.setdefault("path", path)
)
headers = {"Authorization": f"Bearer {token}"}
headers = {
"Authorization": f"Bearer {token}",
"X-SeedPass-Password": "pw",
}
res = cl.get("/api/v1/parent-seed", headers=headers)
assert res.status_code == 200
@@ -174,6 +177,9 @@ def test_parent_seed_endpoint(client, tmp_path):
assert res.json() == {"status": "saved", "path": str(out)}
assert called["path"] == out
res = cl.get("/api/v1/parent-seed", headers={"Authorization": f"Bearer {token}"})
assert res.status_code == 401
def test_fingerprint_endpoints(client):
cl, token = client
@@ -330,11 +336,17 @@ def test_vault_export_endpoint(client, tmp_path):
api._pm.handle_export_database = lambda: out
headers = {"Authorization": f"Bearer {token}"}
headers = {
"Authorization": f"Bearer {token}",
"X-SeedPass-Password": "pw",
}
res = cl.post("/api/v1/vault/export", headers=headers)
assert res.status_code == 200
assert res.content == b"data"
res = cl.post("/api/v1/vault/export", headers={"Authorization": f"Bearer {token}"})
assert res.status_code == 401
def test_backup_parent_seed_endpoint(client, tmp_path):
cl, token = client