keychain_max/apps/api/app/main.py
2026-09-09 21:08:37 +00:00

398 lines
14 KiB
Python

from __future__ import annotations
import hashlib
import hmac
import json
import os
import re
import secrets
import sqlite3
import time
from collections import defaultdict, deque
from contextlib import contextmanager
from datetime import datetime, timedelta, timezone
from pathlib import Path
from typing import Any, Iterator
from uuid import uuid4
from argon2 import PasswordHasher
from argon2.exceptions import InvalidHashError, VerificationError, VerifyMismatchError
from fastapi import Depends, FastAPI, HTTPException, Request, Response, status
from fastapi.middleware.cors import CORSMiddleware
from pydantic import BaseModel, Field, field_validator
ROOT = Path(__file__).resolve().parents[1]
DB_PATH = Path(os.getenv("KEYCHAIN_DB_PATH", str(ROOT / "data" / "keychain.db")))
DB_PATH.parent.mkdir(parents=True, exist_ok=True)
DATABASE_URL = os.getenv("KEYCHAIN_DATABASE_URL", "").strip()
SESSION_TTL = int(os.getenv("KEYCHAIN_SESSION_TTL_SECONDS", "1209600"))
COOKIE_SECURE = os.getenv("KEYCHAIN_COOKIE_SECURE", "false").lower() == "true"
COOKIE_SAMESITE = "lax"
SESSION_COOKIE = "kc_session"
CSRF_COOKIE = "kc_csrf"
configured_origins = [
origin.strip()
for origin in os.getenv(
"KEYCHAIN_ALLOWED_ORIGINS", "http://localhost:5173,http://127.0.0.1:5173"
).split(",")
if origin.strip()
]
configured_extension_id = os.getenv("KEYCHAIN_EXTENSION_ID", "").strip()
extension_origin_regex = (
rf"^chrome-extension://{re.escape(configured_extension_id)}$" if configured_extension_id else None
)
if DATABASE_URL:
import psycopg
from psycopg.rows import dict_row
password_hasher = PasswordHasher()
email_pattern = re.compile(r"^[^\s@]+@[^\s@]+\.[^\s@]+$")
auth_attempts: dict[str, deque[float]] = defaultdict(deque)
def utc_now() -> datetime:
return datetime.now(timezone.utc)
def iso_now() -> str:
return utc_now().isoformat()
def hash_token(token: str) -> str:
return hashlib.sha256(token.encode("utf-8")).hexdigest()
@contextmanager
def db() -> Iterator[Any]:
if DATABASE_URL:
connection = psycopg.connect(DATABASE_URL, row_factory=dict_row)
else:
connection = sqlite3.connect(DB_PATH, timeout=10)
connection.row_factory = sqlite3.Row
connection.execute("PRAGMA foreign_keys = ON")
try:
yield connection
connection.commit()
except BaseException:
connection.rollback()
raise
finally:
connection.close()
def execute(connection: Any, query: str, params: tuple[Any, ...] = ()) -> Any:
"""Keep the small dev SQLite adapter and production psycopg adapter aligned."""
if DATABASE_URL:
query = query.replace("?", "%s")
return connection.execute(query, params)
def init_db() -> None:
with db() as connection:
schema = """
CREATE TABLE IF NOT EXISTS users (
id TEXT PRIMARY KEY,
email TEXT NOT NULL UNIQUE,
display_name TEXT NOT NULL,
password_hash TEXT NOT NULL,
created_at TEXT NOT NULL
);
CREATE TABLE IF NOT EXISTS vaults (
user_id TEXT PRIMARY KEY REFERENCES users(id) ON DELETE CASCADE,
envelope TEXT,
revision INTEGER NOT NULL DEFAULT 0,
updated_at TEXT NOT NULL
);
CREATE TABLE IF NOT EXISTS sessions (
id TEXT PRIMARY KEY,
user_id TEXT NOT NULL REFERENCES users(id) ON DELETE CASCADE,
token_hash TEXT NOT NULL UNIQUE,
csrf_hash TEXT NOT NULL,
created_at TEXT NOT NULL,
expires_at TEXT NOT NULL,
last_seen_at TEXT NOT NULL
);
CREATE INDEX IF NOT EXISTS sessions_token_idx ON sessions(token_hash);
CREATE INDEX IF NOT EXISTS sessions_user_idx ON sessions(user_id);
"""
if DATABASE_URL:
for statement in schema.split(";"):
if statement.strip():
execute(connection, statement)
else:
connection.executescript(schema)
init_db()
class AuthPayload(BaseModel):
email: str = Field(min_length=3, max_length=254)
password: str = Field(min_length=8, max_length=128)
display_name: str | None = Field(default=None, max_length=80)
@field_validator("email")
@classmethod
def normalize_email(cls, value: str) -> str:
normalized = value.strip().lower()
if not email_pattern.fullmatch(normalized):
raise ValueError("Enter a valid email address")
return normalized
@field_validator("password")
@classmethod
def reject_control_chars(cls, value: str) -> str:
if any(ord(character) < 32 for character in value):
raise ValueError("Password contains unsupported control characters")
return value
class VaultPayload(BaseModel):
envelope: dict[str, Any] = Field(min_length=1)
revision: int = Field(ge=0)
@field_validator("envelope")
@classmethod
def validate_size(cls, value: dict[str, Any]) -> dict[str, Any]:
serialized = json.dumps(value, separators=(",", ":"), ensure_ascii=False)
if len(serialized.encode("utf-8")) > 8 * 1024 * 1024:
raise ValueError("Vault envelope is too large")
return value
def public_user(row: Any) -> dict[str, str]:
return {
"id": row["id"],
"email": row["email"],
"display_name": row["display_name"],
}
def enforce_auth_rate_limit(request: Request) -> None:
address = request.client.host if request.client else "unknown"
now = time.monotonic()
attempts = auth_attempts[address]
while attempts and attempts[0] < now - 60:
attempts.popleft()
if len(attempts) >= 12:
raise HTTPException(status_code=429, detail="Too many attempts. Try again in a minute.")
attempts.append(now)
def issue_session(response: Response, user_id: str) -> str:
raw_session = secrets.token_urlsafe(32)
raw_csrf = secrets.token_urlsafe(24)
now = utc_now()
expires = now + timedelta(seconds=SESSION_TTL)
with db() as connection:
execute(connection,
"INSERT INTO sessions (id, user_id, token_hash, csrf_hash, created_at, expires_at, last_seen_at) "
"VALUES (?, ?, ?, ?, ?, ?, ?)",
(
uuid4().hex,
user_id,
hash_token(raw_session),
hash_token(raw_csrf),
now.isoformat(),
expires.isoformat(),
now.isoformat(),
),
)
response.set_cookie(
SESSION_COOKIE,
raw_session,
max_age=SESSION_TTL,
httponly=True,
secure=COOKIE_SECURE,
samesite=COOKIE_SAMESITE,
path="/",
)
response.set_cookie(
CSRF_COOKIE,
raw_csrf,
max_age=SESSION_TTL,
httponly=False,
secure=COOKIE_SECURE,
samesite=COOKIE_SAMESITE,
path="/",
)
return raw_csrf
def current_session(request: Request) -> sqlite3.Row:
raw_token = request.cookies.get(SESSION_COOKIE)
if not raw_token:
raise HTTPException(status_code=401, detail="Authentication required")
now = utc_now()
with db() as connection:
session = execute(connection,
"SELECT * FROM sessions WHERE token_hash = ?", (hash_token(raw_token),)
).fetchone()
if session is None:
raise HTTPException(status_code=401, detail="Session is invalid")
if datetime.fromisoformat(session["expires_at"]) <= now:
execute(connection, "DELETE FROM sessions WHERE id = ?", (session["id"],))
raise HTTPException(status_code=401, detail="Session has expired")
execute(connection,
"UPDATE sessions SET last_seen_at = ? WHERE id = ?", (now.isoformat(), session["id"])
)
return session
def require_csrf(request: Request, session: sqlite3.Row) -> None:
header_token = request.headers.get("x-csrf-token", "")
cookie_token = request.cookies.get(CSRF_COOKIE, "")
if not header_token or not hmac.compare_digest(header_token, cookie_token):
raise HTTPException(status_code=403, detail="CSRF validation failed")
if not hmac.compare_digest(hash_token(header_token), session["csrf_hash"]):
raise HTTPException(status_code=403, detail="CSRF token is invalid")
def user_for_session(session: Any) -> Any:
with db() as connection:
user = execute(connection, "SELECT * FROM users WHERE id = ?", (session["user_id"],)).fetchone()
if user is None:
raise HTTPException(status_code=401, detail="Account no longer exists")
return user
app = FastAPI(title="Keychain API", version="0.1.0")
app.add_middleware(
CORSMiddleware,
allow_origins=configured_origins,
allow_origin_regex=extension_origin_regex,
allow_credentials=True,
allow_methods=["GET", "POST", "PUT", "OPTIONS"],
allow_headers=["Content-Type", "X-CSRF-Token"],
)
@app.middleware("http")
async def security_headers(request: Request, call_next):
response = await call_next(request)
response.headers["X-Content-Type-Options"] = "nosniff"
response.headers["X-Frame-Options"] = "DENY"
response.headers["Referrer-Policy"] = "strict-origin-when-cross-origin"
response.headers["Permissions-Policy"] = "camera=(), microphone=(), geolocation=()"
return response
@app.get("/api/health")
def health() -> dict[str, str]:
try:
with db() as connection:
execute(connection, "SELECT 1").fetchone()
except Exception as error:
raise HTTPException(status_code=503, detail="Database is not ready") from error
return {"status": "ok", "database": "ready"}
@app.post("/api/auth/register", status_code=status.HTTP_201_CREATED)
def register(payload: AuthPayload, request: Request, response: Response) -> dict[str, Any]:
enforce_auth_rate_limit(request)
user_id = uuid4().hex
display_name = (payload.display_name or payload.email.split("@", 1)[0]).strip()[:80]
now = iso_now()
try:
with db() as connection:
execute(connection,
"INSERT INTO users (id, email, display_name, password_hash, created_at) VALUES (?, ?, ?, ?, ?)",
(user_id, payload.email, display_name, password_hasher.hash(payload.password), now),
)
execute(connection,
"INSERT INTO vaults (user_id, envelope, revision, updated_at) VALUES (?, ?, 0, ?)",
(user_id, None, now),
)
except Exception as error:
error_text = str(error).lower()
if "email" in error_text or "unique" in error_text or "duplicate" in error_text:
raise HTTPException(status_code=409, detail="An account with this email already exists") from error
raise
csrf_token = issue_session(response, user_id)
with db() as connection:
user = execute(connection, "SELECT * FROM users WHERE id = ?", (user_id,)).fetchone()
return {"user": public_user(user), "csrf_token": csrf_token}
@app.post("/api/auth/login")
def login(payload: AuthPayload, request: Request, response: Response) -> dict[str, Any]:
enforce_auth_rate_limit(request)
with db() as connection:
user = execute(connection, "SELECT * FROM users WHERE email = ?", (payload.email,)).fetchone()
if user is None:
raise HTTPException(status_code=401, detail="Email or password is incorrect")
try:
password_hasher.verify(user["password_hash"], payload.password)
except (VerifyMismatchError, VerificationError, InvalidHashError) as error:
raise HTTPException(status_code=401, detail="Email or password is incorrect") from error
if password_hasher.check_needs_rehash(user["password_hash"]):
with db() as connection:
execute(connection,
"UPDATE users SET password_hash = ? WHERE id = ?",
(password_hasher.hash(payload.password), user["id"]),
)
csrf_token = issue_session(response, user["id"])
return {"user": public_user(user), "csrf_token": csrf_token}
@app.get("/api/auth/me")
def me(session: sqlite3.Row = Depends(current_session)) -> dict[str, Any]:
return {"user": public_user(user_for_session(session))}
@app.post("/api/auth/logout")
def logout(request: Request, response: Response, session: sqlite3.Row = Depends(current_session)) -> dict[str, str]:
require_csrf(request, session)
with db() as connection:
execute(connection, "DELETE FROM sessions WHERE id = ?", (session["id"],))
response.delete_cookie(SESSION_COOKIE, path="/")
response.delete_cookie(CSRF_COOKIE, path="/")
return {"status": "signed_out"}
@app.get("/api/vault")
def get_vault(session: sqlite3.Row = Depends(current_session)) -> dict[str, Any]:
user = user_for_session(session)
with db() as connection:
vault = execute(connection, "SELECT * FROM vaults WHERE user_id = ?", (user["id"],)).fetchone()
if vault is None:
raise HTTPException(status_code=404, detail="Vault not found")
return {
"envelope": json.loads(vault["envelope"]) if vault["envelope"] else None,
"revision": vault["revision"],
"updated_at": vault["updated_at"],
}
@app.put("/api/vault")
def put_vault(
payload: VaultPayload,
request: Request,
session: sqlite3.Row = Depends(current_session),
) -> dict[str, Any]:
require_csrf(request, session)
with db() as connection:
vault = execute(connection,
"SELECT revision FROM vaults WHERE user_id = ?", (session["user_id"],)
).fetchone()
if vault is None:
raise HTTPException(status_code=404, detail="Vault not found")
if vault["revision"] != payload.revision:
raise HTTPException(status_code=409, detail="Vault changed on another device; reload before saving")
new_revision = payload.revision + 1
updated_at = iso_now()
updated = execute(connection,
"UPDATE vaults SET envelope = ?, revision = ?, updated_at = ? WHERE user_id = ?",
(
json.dumps(payload.envelope, separators=(",", ":"), ensure_ascii=False),
new_revision,
updated_at,
session["user_id"],
),
)
if updated.rowcount != 1:
raise HTTPException(status_code=409, detail="Vault changed on another device; reload before saving")
return {"revision": new_revision, "updated_at": updated_at}