Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 4 additions & 0 deletions apps/api/.env.example
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,10 @@ AUTH_DISABLED=true
GOOGLE_CLIENT_ID=
GOOGLE_CLIENT_SECRET=

# Credential encryption (AES-256-GCM, base64-encoded 32-byte key)
# Generate with: python -c "import secrets,base64; print(base64.b64encode(secrets.token_bytes(32)).decode())"
CREDENTIAL_ENCRYPTION_KEY=

# API Settings
API_HOST=0.0.0.0
API_PORT=8000
Expand Down
3 changes: 3 additions & 0 deletions apps/api/app/lib/config.py
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,9 @@ class Settings(BaseSettings):
google_client_id: str = ""
google_client_secret: str = ""

# Credential encryption (base64-encoded 32-byte AES-256-GCM key)
credential_encryption_key: str = ""

# Development mode
auth_disabled: bool = True

Expand Down
69 changes: 69 additions & 0 deletions apps/api/app/lib/crypto.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,69 @@
"""AES-256-GCM encryption for credential storage."""

import base64
import os

from cryptography.hazmat.primitives.ciphers.aead import AESGCM

from app.lib.config import get_settings

_cached_key: bytes | None = None


def _get_key() -> bytes:
"""Read and cache the base64-decoded 32-byte encryption key from settings."""
global _cached_key
if _cached_key is not None:
return _cached_key

raw = get_settings().credential_encryption_key
if not raw:
raise RuntimeError(
"CREDENTIAL_ENCRYPTION_KEY is not set. "
'Generate one with: python -c "import secrets,base64; '
'print(base64.b64encode(secrets.token_bytes(32)).decode())"'
)

key = base64.b64decode(raw)
if len(key) != 32:
raise RuntimeError("CREDENTIAL_ENCRYPTION_KEY must be exactly 32 bytes (256 bits)")

_cached_key = key
return _cached_key


def encrypt(plaintext: str) -> str:
"""Encrypt a string with AES-256-GCM. Returns base64(nonce + ciphertext + tag)."""
key = _get_key()
nonce = os.urandom(12)
aesgcm = AESGCM(key)
ct = aesgcm.encrypt(nonce, plaintext.encode("utf-8"), None)
# ct already includes the 16-byte GCM tag appended by cryptography
return base64.b64encode(nonce + ct).decode("ascii")


def decrypt(ciphertext_b64: str) -> str:
"""Decrypt a base64-encoded AES-256-GCM blob. Returns plaintext string."""
key = _get_key()
raw = base64.b64decode(ciphertext_b64)
if len(raw) < 28: # 12 nonce + 16 tag minimum
raise ValueError("Ciphertext too short")
nonce = raw[:12]
ct = raw[12:]
aesgcm = AESGCM(key)
plaintext = aesgcm.decrypt(nonce, ct, None)
return plaintext.decode("utf-8")


def mask_key(key: str) -> str:
"""Return a masked display hint, e.g. 'sk-...7f3a'.

Uses prefix up to first '-' (or first 2 chars) + '...' + last 4 chars.
"""
suffix = key[-4:]
dash_idx = key.find("-")
if dash_idx > 0 and dash_idx < len(key) - 4:
prefix = key[: dash_idx + 1]
else:
prefix = key[:2]
return f"{prefix}...{suffix}"
3 changes: 2 additions & 1 deletion apps/api/app/main.py
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,7 @@
from fastapi import FastAPI
from fastapi.middleware.cors import CORSMiddleware

from app.routes import auth, health, websocket
from app.routes import auth, health, keys, websocket


def create_app() -> FastAPI:
Expand Down Expand Up @@ -33,6 +33,7 @@ def create_app() -> FastAPI:
# Include routers
app.include_router(auth.router, prefix="/api")
app.include_router(health.router, prefix="/api")
app.include_router(keys.router, prefix="/api")
app.include_router(websocket.router, prefix="/api")

return app
Expand Down
37 changes: 37 additions & 0 deletions apps/api/app/models/credential.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,37 @@
"""Credential document model for encrypted API key storage."""

from dataclasses import dataclass, field
from datetime import UTC, datetime
from typing import Any, ClassVar

from app.models import BaseDocument


@dataclass
class CredentialDocument(BaseDocument):
"""Credential stored as a subcollection under users/{uid}/credentials."""

COLLECTION: ClassVar[str] = "credentials"

provider: str = ""
name: str = ""
encrypted_key: str = ""
key_suffix: str = ""

# Override base fields with defaults
id: str = ""
created_at: datetime = field(default_factory=lambda: datetime.now(UTC))
updated_at: datetime = field(default_factory=lambda: datetime.now(UTC))

@classmethod
def from_dict(cls, doc_id: str, data: dict[str, Any]) -> "CredentialDocument":
"""Create CredentialDocument instance from Firestore document data."""
return cls(
id=doc_id,
provider=data.get("provider", ""),
name=data.get("name", ""),
encrypted_key=data.get("encrypted_key", ""),
key_suffix=data.get("key_suffix", ""),
created_at=data.get("created_at", datetime.now(UTC)),
updated_at=data.get("updated_at", datetime.now(UTC)),
)
64 changes: 64 additions & 0 deletions apps/api/app/repositories/credential.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,64 @@
"""Credential repository for user-scoped Firestore subcollection."""

from datetime import UTC, datetime
from typing import TYPE_CHECKING, Any, cast

if TYPE_CHECKING:
from google.cloud.firestore_v1 import Client

from app.models.credential import CredentialDocument


class CredentialRepository:
"""Repository for credentials stored under users/{uid}/credentials."""

def __init__(self, db: "Client", user_id: str):
"""Initialize with Firestore client and the owning user's UID."""
self.db = db
self.collection = db.collection("users").document(user_id).collection("credentials")

def _to_model(self, doc_id: str, data: dict[str, Any]) -> CredentialDocument:
"""Convert Firestore document data to model instance."""
return cast(CredentialDocument, CredentialDocument.from_dict(doc_id, data))

def create(self, doc_id: str, model: CredentialDocument) -> CredentialDocument:
"""Create a new credential document."""
now = datetime.now(UTC)
model.created_at = now
model.updated_at = now
data = model.to_dict()
self.collection.document(doc_id).set(data)
model.id = doc_id
return model

def get(self, doc_id: str) -> CredentialDocument | None:
"""Get a credential by ID."""
doc = self.collection.document(doc_id).get() # type: ignore[union-attr]
if not doc.exists: # type: ignore[union-attr]
return None
return self._to_model(doc.id, doc.to_dict() or {}) # type: ignore[union-attr]

def list(self, limit: int = 50) -> list[CredentialDocument]:
"""List all credentials for this user."""
docs = self.collection.limit(limit).stream()
return [self._to_model(doc.id, doc.to_dict() or {}) for doc in docs] # type: ignore[union-attr]

def update(self, doc_id: str, data: dict[str, Any]) -> CredentialDocument | None:
"""Update a credential with partial data."""
doc_ref = self.collection.document(doc_id)
doc = doc_ref.get() # type: ignore[union-attr]
if not doc.exists: # type: ignore[union-attr]
return None
data["updated_at"] = datetime.now(UTC)
doc_ref.update(data)
updated_doc = doc_ref.get() # type: ignore[union-attr]
return self._to_model(updated_doc.id, updated_doc.to_dict() or {}) # type: ignore[union-attr]

def delete(self, doc_id: str) -> bool:
"""Delete a credential by ID."""
doc_ref = self.collection.document(doc_id)
doc = doc_ref.get() # type: ignore[union-attr]
if not doc.exists: # type: ignore[union-attr]
return False
doc_ref.delete()
return True
167 changes: 167 additions & 0 deletions apps/api/app/routes/keys.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,167 @@
"""API routes for encrypted credential (API key) management."""

import uuid

from fastapi import APIRouter, HTTPException, status
from pydantic import BaseModel

from app.lib.config import get_settings
from app.lib.crypto import encrypt, mask_key
from app.lib.firebase import get_firestore_client
from app.middleware.auth import CurrentUser
from app.models.credential import CredentialDocument
from app.repositories.credential import CredentialRepository

router = APIRouter(tags=["keys"])


# ---------------------------------------------------------------------------
# Request / Response schemas
# ---------------------------------------------------------------------------


class CreateKeyRequest(BaseModel):
provider: str
name: str
key: str


class UpdateKeyRequest(BaseModel):
name: str | None = None
key: str | None = None


class KeyResponse(BaseModel):
id: str
provider: str
name: str
key_hint: str
created_at: str
updated_at: str


# ---------------------------------------------------------------------------
# Helpers
# ---------------------------------------------------------------------------


def _ensure_encryption_configured() -> None:
"""Raise 500 if the encryption key is not set."""
if not get_settings().credential_encryption_key:
raise HTTPException(
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
detail="Credential encryption is not configured",
)


def _to_response(doc: CredentialDocument) -> KeyResponse:
"""Convert a CredentialDocument to the API response model."""
return KeyResponse(
id=doc.id,
provider=doc.provider,
name=doc.name,
key_hint=doc.key_suffix,
created_at=doc.created_at.isoformat()
if hasattr(doc.created_at, "isoformat")
else str(doc.created_at),
updated_at=doc.updated_at.isoformat()
if hasattr(doc.updated_at, "isoformat")
else str(doc.updated_at),
)


def _get_repo(user_uid: str) -> CredentialRepository:
db = get_firestore_client()
return CredentialRepository(db, user_uid)


# ---------------------------------------------------------------------------
# Endpoints
# ---------------------------------------------------------------------------


@router.post("/keys", status_code=status.HTTP_201_CREATED)
async def create_key(body: CreateKeyRequest, current_user: CurrentUser) -> KeyResponse:
"""Create a new encrypted API key credential."""
_ensure_encryption_configured()

if not body.key or len(body.key) < 8:
raise HTTPException(
status_code=status.HTTP_422_UNPROCESSABLE_ENTITY,
detail="API key must be at least 8 characters",
)

encrypted = encrypt(body.key)
suffix = mask_key(body.key)
doc_id = str(uuid.uuid4())

doc = CredentialDocument(
provider=body.provider,
name=body.name,
encrypted_key=encrypted,
key_suffix=suffix,
)

repo = _get_repo(current_user.uid)
created = repo.create(doc_id, doc)
return _to_response(created)


@router.get("/keys")
async def list_keys(current_user: CurrentUser) -> list[KeyResponse]:
"""List all credentials for the authenticated user (masked, never decrypted)."""
repo = _get_repo(current_user.uid)
docs = repo.list()
return [_to_response(doc) for doc in docs]


@router.get("/keys/{key_id}")
async def get_key(key_id: str, current_user: CurrentUser) -> KeyResponse:
"""Get a single credential by ID (masked)."""
repo = _get_repo(current_user.uid)
doc = repo.get(key_id)
if not doc:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Credential not found")
return _to_response(doc)


@router.put("/keys/{key_id}")
async def update_key(key_id: str, body: UpdateKeyRequest, current_user: CurrentUser) -> KeyResponse:
"""Update a credential's name and/or re-encrypt its key."""
repo = _get_repo(current_user.uid)
existing = repo.get(key_id)
if not existing:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Credential not found")

update_data: dict = {}

if body.name is not None:
update_data["name"] = body.name

if body.key is not None:
_ensure_encryption_configured()
if len(body.key) < 8:
raise HTTPException(
status_code=status.HTTP_422_UNPROCESSABLE_ENTITY,
detail="API key must be at least 8 characters",
)
update_data["encrypted_key"] = encrypt(body.key)
update_data["key_suffix"] = mask_key(body.key)

updated = repo.update(key_id, update_data)
if not updated:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Credential not found")
return _to_response(updated)


@router.delete("/keys/{key_id}", status_code=status.HTTP_204_NO_CONTENT)
async def delete_key(key_id: str, current_user: CurrentUser) -> None:
"""Delete a credential. Overwrites the encrypted blob before removal."""
repo = _get_repo(current_user.uid)
existing = repo.get(key_id)
if not existing:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Credential not found")

# Overwrite the encrypted key before deletion
repo.update(key_id, {"encrypted_key": ""})
repo.delete(key_id)
1 change: 1 addition & 0 deletions apps/api/requirements.txt
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@ pydantic-settings>=2.2.0
python-multipart>=0.0.9
httpx>=0.27.0
firebase-admin>=6.4.0
cryptography>=44.0.0

# Dev dependencies
pytest>=8.0.0
Expand Down
Loading