mirror of
https://github.com/PR0M3TH3AN/SeedPass.git
synced 2025-09-07 14:58:56 +00:00
feat: add short-lived JWT auth and secure endpoints
This commit is contained in:
24
README.md
24
README.md
@@ -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 TLS‑terminating 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:
|
||||
|
@@ -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 one‑time token used for authentication:
|
||||
Run `seedpass api start` from your terminal. The command prints a short‑lived 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
|
||||
```
|
||||
|
||||
|
@@ -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
|
||||
|
@@ -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:
|
||||
|
@@ -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")
|
||||
|
@@ -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
|
||||
|
Reference in New Issue
Block a user