diff --git a/apps/api/.env.example b/apps/api/.env.example index f538f79..98abebf 100644 --- a/apps/api/.env.example +++ b/apps/api/.env.example @@ -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 diff --git a/apps/api/app/lib/config.py b/apps/api/app/lib/config.py index 83d34bb..8c05a6a 100644 --- a/apps/api/app/lib/config.py +++ b/apps/api/app/lib/config.py @@ -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 diff --git a/apps/api/app/lib/crypto.py b/apps/api/app/lib/crypto.py new file mode 100644 index 0000000..2fb7c86 --- /dev/null +++ b/apps/api/app/lib/crypto.py @@ -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}" diff --git a/apps/api/app/main.py b/apps/api/app/main.py index a92f4f7..a05605e 100644 --- a/apps/api/app/main.py +++ b/apps/api/app/main.py @@ -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: @@ -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 diff --git a/apps/api/app/models/credential.py b/apps/api/app/models/credential.py new file mode 100644 index 0000000..36cba17 --- /dev/null +++ b/apps/api/app/models/credential.py @@ -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)), + ) diff --git a/apps/api/app/repositories/credential.py b/apps/api/app/repositories/credential.py new file mode 100644 index 0000000..fd9581f --- /dev/null +++ b/apps/api/app/repositories/credential.py @@ -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 diff --git a/apps/api/app/routes/keys.py b/apps/api/app/routes/keys.py new file mode 100644 index 0000000..674c759 --- /dev/null +++ b/apps/api/app/routes/keys.py @@ -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) diff --git a/apps/api/requirements.txt b/apps/api/requirements.txt index 6b0af8d..cd586b3 100644 --- a/apps/api/requirements.txt +++ b/apps/api/requirements.txt @@ -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 diff --git a/apps/api/tests/test_keys.py b/apps/api/tests/test_keys.py new file mode 100644 index 0000000..9a229f8 --- /dev/null +++ b/apps/api/tests/test_keys.py @@ -0,0 +1,232 @@ +"""Tests for credential (API key) encryption and CRUD endpoints.""" + +import base64 +import os +import secrets +from collections.abc import Generator + +import pytest +from fastapi.testclient import TestClient + +# --------------------------------------------------------------------------- +# Fixtures +# --------------------------------------------------------------------------- + +# Generate a test encryption key before importing the app +_TEST_KEY = base64.b64encode(secrets.token_bytes(32)).decode() +os.environ["CREDENTIAL_ENCRYPTION_KEY"] = _TEST_KEY + + +@pytest.fixture(autouse=True) +def _reset_crypto_cache() -> Generator[None]: + """Reset the cached encryption key between tests.""" + import app.lib.crypto as crypto_mod + + crypto_mod._cached_key = None + yield + crypto_mod._cached_key = None + + +# --------------------------------------------------------------------------- +# Unit tests — encryption module +# --------------------------------------------------------------------------- + + +class TestEncryptDecrypt: + """Unit tests for the crypto module (no Firestore required).""" + + def test_encrypt_decrypt_roundtrip(self) -> None: + from app.lib.crypto import decrypt, encrypt + + keys = [ + "sk-abc123def456ghij", + "AIzaSyA1234567890abcdefg", + "a" * 100, + "special-chars!@#$%^&*()", + ] + for key in keys: + assert decrypt(encrypt(key)) == key + + def test_encrypt_produces_unique_ciphertext(self) -> None: + from app.lib.crypto import encrypt + + plaintext = "sk-test-key-12345678" + ct1 = encrypt(plaintext) + ct2 = encrypt(plaintext) + assert ct1 != ct2, "Two encryptions of the same plaintext should produce different blobs" + + def test_mask_key_with_dash_prefix(self) -> None: + from app.lib.crypto import mask_key + + result = mask_key("sk-abc123def456") + assert result == "sk-...f456" + + def test_mask_key_without_dash(self) -> None: + from app.lib.crypto import mask_key + + result = mask_key("AIzaSyA1234567890") + assert result == "AI...7890" + + def test_mask_key_multi_dash(self) -> None: + from app.lib.crypto import mask_key + + result = mask_key("sk-proj-abc123def456") + assert result == "sk-...f456" + + def test_decrypt_wrong_key_fails(self) -> None: + from app.lib.config import get_settings + from app.lib.crypto import encrypt + + ciphertext = encrypt("sk-test-key-12345678") + + # Switch to a different key + import app.lib.crypto as crypto_mod + + other_key = base64.b64encode(secrets.token_bytes(32)).decode() + os.environ["CREDENTIAL_ENCRYPTION_KEY"] = other_key + crypto_mod._cached_key = None + get_settings.cache_clear() + + from app.lib.crypto import decrypt + + with pytest.raises(Exception): + decrypt(ciphertext) + + # Restore original key + os.environ["CREDENTIAL_ENCRYPTION_KEY"] = _TEST_KEY + crypto_mod._cached_key = None + get_settings.cache_clear() + + def test_decrypt_corrupted_blob_fails(self) -> None: + from app.lib.crypto import decrypt, encrypt + + ct = encrypt("sk-test-key-12345678") + raw = bytearray(base64.b64decode(ct)) + raw[-1] ^= 0xFF # flip a byte + corrupted = base64.b64encode(bytes(raw)).decode() + + with pytest.raises(Exception): + decrypt(corrupted) + + +# --------------------------------------------------------------------------- +# Integration tests — CRUD endpoints +# --------------------------------------------------------------------------- + + +class TestKeysEndpoints: + """Integration tests for the /api/keys endpoints.""" + + def test_create_key(self, client: TestClient) -> None: + resp = client.post( + "/api/keys", + json={"provider": "anthropic", "name": "My Claude Key", "key": "sk-ant-12345678"}, + ) + assert resp.status_code == 201 + data = resp.json() + assert data["provider"] == "anthropic" + assert data["name"] == "My Claude Key" + assert data["key_hint"] == "sk-...5678" + assert "id" in data + assert "created_at" in data + # Ensure the plaintext key is NOT in the response + assert "sk-ant-12345678" not in resp.text + + def test_list_keys_masked(self, client: TestClient) -> None: + # Create two keys + client.post( + "/api/keys", + json={"provider": "anthropic", "name": "Key A", "key": "sk-ant-aaaabbbb"}, + ) + client.post( + "/api/keys", + json={"provider": "openai", "name": "Key B", "key": "sk-openai-ccccdddd"}, + ) + + resp = client.get("/api/keys") + assert resp.status_code == 200 + data = resp.json() + assert isinstance(data, list) + assert len(data) >= 2 + for item in data: + assert "key_hint" in item + # No plaintext key field + assert "key" not in item + assert "encrypted_key" not in item + + def test_get_key_masked(self, client: TestClient) -> None: + create_resp = client.post( + "/api/keys", + json={"provider": "google", "name": "GCP Key", "key": "AIzaSyA1234567890abcdefg"}, + ) + key_id = create_resp.json()["id"] + + resp = client.get(f"/api/keys/{key_id}") + assert resp.status_code == 200 + data = resp.json() + assert data["id"] == key_id + assert data["provider"] == "google" + assert data["key_hint"] == "AI...defg" + + def test_update_key_name(self, client: TestClient) -> None: + create_resp = client.post( + "/api/keys", + json={"provider": "openai", "name": "Old Name", "key": "sk-openai-12345678"}, + ) + key_id = create_resp.json()["id"] + + resp = client.put(f"/api/keys/{key_id}", json={"name": "New Name"}) + assert resp.status_code == 200 + assert resp.json()["name"] == "New Name" + + def test_update_key_reencrypt(self, client: TestClient) -> None: + create_resp = client.post( + "/api/keys", + json={"provider": "anthropic", "name": "Reencrypt", "key": "sk-ant-oldkey99"}, + ) + key_id = create_resp.json()["id"] + old_hint = create_resp.json()["key_hint"] + + resp = client.put( + f"/api/keys/{key_id}", + json={"key": "sk-ant-newkey11"}, + ) + assert resp.status_code == 200 + new_hint = resp.json()["key_hint"] + assert new_hint != old_hint + assert new_hint == "sk-...ey11" + + def test_delete_key(self, client: TestClient) -> None: + create_resp = client.post( + "/api/keys", + json={"provider": "anthropic", "name": "Delete Me", "key": "sk-ant-deleteme"}, + ) + key_id = create_resp.json()["id"] + + resp = client.delete(f"/api/keys/{key_id}") + assert resp.status_code == 204 + + resp = client.get(f"/api/keys/{key_id}") + assert resp.status_code == 404 + + def test_create_key_empty_rejected(self, client: TestClient) -> None: + resp = client.post( + "/api/keys", + json={"provider": "anthropic", "name": "Bad Key", "key": ""}, + ) + assert resp.status_code == 422 + + def test_create_key_too_short_rejected(self, client: TestClient) -> None: + resp = client.post( + "/api/keys", + json={"provider": "anthropic", "name": "Short", "key": "abc"}, + ) + assert resp.status_code == 422 + + def test_get_nonexistent_key_404(self, client: TestClient) -> None: + resp = client.get("/api/keys/nonexistent-id") + assert resp.status_code == 404 + + def test_delete_nonexistent_key_404(self, client: TestClient) -> None: + resp = client.delete("/api/keys/nonexistent-id") + assert resp.status_code == 404 diff --git a/apps/desktop/src/lib/api.ts b/apps/desktop/src/lib/api.ts index 9e25d70..ba037b6 100644 --- a/apps/desktop/src/lib/api.ts +++ b/apps/desktop/src/lib/api.ts @@ -86,6 +86,90 @@ export async function exchangeGoogleAuthCode( return response.json(); } +// --------------------------------------------------------------------------- +// Credentials (API keys) +// --------------------------------------------------------------------------- + +export interface Credential { + id: string; + provider: string; + name: string; + key_hint: string; + created_at: string; + updated_at: string; +} + +/** + * Create a new encrypted API key credential. + */ +export async function createKey( + provider: string, + name: string, + key: string +): Promise { + const response = await fetchWithAuth(`${API_BASE_URL}/keys`, { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ provider, name, key }), + }); + if (!response.ok) { + throw new Error(`Failed to create key: ${response.statusText}`); + } + return response.json(); +} + +/** + * List all credentials for the authenticated user (masked). + */ +export async function listKeys(): Promise { + const response = await fetchWithAuth(`${API_BASE_URL}/keys`); + if (!response.ok) { + throw new Error(`Failed to list keys: ${response.statusText}`); + } + return response.json(); +} + +/** + * Get a single credential by ID (masked). + */ +export async function getKey(id: string): Promise { + const response = await fetchWithAuth(`${API_BASE_URL}/keys/${id}`); + if (!response.ok) { + throw new Error(`Failed to get key: ${response.statusText}`); + } + return response.json(); +} + +/** + * Update a credential's name and/or re-encrypt its key. + */ +export async function updateKey( + id: string, + data: { name?: string; key?: string } +): Promise { + const response = await fetchWithAuth(`${API_BASE_URL}/keys/${id}`, { + method: "PUT", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify(data), + }); + if (!response.ok) { + throw new Error(`Failed to update key: ${response.statusText}`); + } + return response.json(); +} + +/** + * Delete a credential. + */ +export async function deleteKey(id: string): Promise { + const response = await fetchWithAuth(`${API_BASE_URL}/keys/${id}`, { + method: "DELETE", + }); + if (!response.ok) { + throw new Error(`Failed to delete key: ${response.statusText}`); + } +} + /** * Create a WebSocket connection to the API. */ diff --git a/infra/terraform/modules/mcontrol/cloud_run.tf b/infra/terraform/modules/mcontrol/cloud_run.tf index 7579edc..988421b 100644 --- a/infra/terraform/modules/mcontrol/cloud_run.tf +++ b/infra/terraform/modules/mcontrol/cloud_run.tf @@ -47,6 +47,17 @@ resource "google_cloud_run_v2_service" "api" { value = var.api_base_url } + # Credential encryption key from Secret Manager + env { + name = "CREDENTIAL_ENCRYPTION_KEY" + value_source { + secret_key_ref { + secret = google_secret_manager_secret.credential_encryption_key.secret_id + version = "latest" + } + } + } + # Google OAuth secrets from Secret Manager env { name = "GOOGLE_CLIENT_ID" diff --git a/infra/terraform/modules/mcontrol/secrets.tf b/infra/terraform/modules/mcontrol/secrets.tf index 4a582f6..7f378d7 100644 --- a/infra/terraform/modules/mcontrol/secrets.tf +++ b/infra/terraform/modules/mcontrol/secrets.tf @@ -65,3 +65,34 @@ resource "google_secret_manager_secret_iam_member" "api_google_client_secret" { role = "roles/secretmanager.secretAccessor" member = "serviceAccount:${google_service_account.api.email}" } + +# Credential encryption key for AES-256-GCM encrypted API key storage +resource "google_secret_manager_secret" "credential_encryption_key" { + project = var.project_id + secret_id = "credential-encryption-key-${var.environment}" + + labels = local.labels + + replication { + auto {} + } + + depends_on = [google_project_service.apis] +} + +resource "google_secret_manager_secret_version" "credential_encryption_key_initial" { + secret = google_secret_manager_secret.credential_encryption_key.id + secret_data = "PLACEHOLDER" + + lifecycle { + # Don't revert to placeholder when real value is set via gcloud CLI + ignore_changes = [secret_data] + } +} + +resource "google_secret_manager_secret_iam_member" "api_credential_encryption_key" { + project = var.project_id + secret_id = google_secret_manager_secret.credential_encryption_key.secret_id + role = "roles/secretmanager.secretAccessor" + member = "serviceAccount:${google_service_account.api.email}" +} diff --git a/packages/shared/src/schemas.ts b/packages/shared/src/schemas.ts index 9135897..c0f9cca 100644 --- a/packages/shared/src/schemas.ts +++ b/packages/shared/src/schemas.ts @@ -68,6 +68,7 @@ export const Credential = z.object({ name: z.string().min(1).max(255), type: CredentialType, provider: Provider, + keyHint: z.string().optional(), // Note: actual secret values are never exposed in API responses createdAt: DateTimeString, updatedAt: DateTimeString, diff --git a/specs/r1a/03.secure-key-storage.md b/specs/r1a/03.secure-key-storage.md new file mode 100644 index 0000000..328077e --- /dev/null +++ b/specs/r1a/03.secure-key-storage.md @@ -0,0 +1,406 @@ +# Secure Key Storage & Encryption at Rest + +## Context + +Mission Control needs to store user-provided API keys (Anthropic, OpenAI, Google AI, etc.) so that long-running agents can make outbound LLM calls on the user's behalf. Keys are highly sensitive — a leak exposes billing and data. This task implements encrypted credential storage in Firestore so that plaintext keys are never persisted and are only decrypted in-memory at the moment of an outbound LLM call. + +**After this task, users can add/view/update/delete their API keys through the desktop app. Keys are AES-256-GCM encrypted before being written to Firestore. The API never returns full plaintext keys — only masked suffixes for display.** + +--- + +## Architecture: Per-User Encrypted Subcollections + +Credentials are stored as Firestore subcollection documents under each user: `users/{uid}/credentials/{credentialId}`. Each document contains an AES-256-GCM encrypted blob of the API key, plus metadata (provider, display name, masked suffix). A single symmetric encryption key — loaded from an environment variable (local dev) or GCP Secret Manager (deployed environments) — protects all credential blobs. + +``` +Desktop API Firestore + │ │ │ + ├─ POST /api/keys { provider, name, key } ──► │ │ + │ ├─ validate key format │ + │ ├─ encrypt(key) → ciphertext │ + │ ├─ extract last 4 chars → suffix │ + │ ├─ store { provider, name, ──► │ + │ │ encrypted_key, key_suffix } │ + │◄── { id, provider, name, key_hint } ─────────┤ │ + │ │ │ + ├─ GET /api/keys ───────────────────────────► │ │ + │ ├─ list docs ◄──────────────────────┤ + │◄── [{ id, provider, name, key_hint }] ───────┤ (never decrypt) │ + │ │ │ + ├─ (agent makes LLM call) ───────────────────► │ │ + │ ├─ fetch credential doc ◄───────────┤ + │ ├─ decrypt(encrypted_key) → key │ + │ ├─ call LLM provider with key │ + │ ├─ zero key from memory │ + │ │ (never log plaintext) │ +``` + +--- + +## 1. Encryption Module — `apps/api/app/lib/crypto.py` (new file) + +### 1.1 Algorithm + +AES-256-GCM via Python's `cryptography` library. Each encryption produces a unique random 12-byte nonce. The stored blob format is: + +``` +nonce (12 bytes) || ciphertext || GCM tag (16 bytes) +``` + +The blob is base64-encoded for Firestore string storage. + +### 1.2 Functions + +```python +def encrypt(plaintext: str) -> str: + """Encrypt a string with AES-256-GCM. Returns base64-encoded blob.""" + +def decrypt(ciphertext_b64: str) -> str: + """Decrypt a base64-encoded AES-256-GCM blob. Returns plaintext string.""" + +def mask_key(key: str) -> str: + """Return a masked display hint, e.g. 'sk-...7f3a' (prefix + last 4 chars).""" + +def zero_string(s: str) -> None: + """Best-effort zeroing of a string's underlying buffer (Python limitation noted).""" +``` + +### 1.3 Key Management + +The encryption key is a 32-byte (256-bit) key, base64-encoded in the environment variable `CREDENTIAL_ENCRYPTION_KEY`. + +- **Local dev:** Set in root `.env` file (gitignored) or `apps/api/.env` +- **Deployed:** Stored in GCP Secret Manager as `credential-encryption-key-{env}`, injected into Cloud Run as an environment variable + +On startup, `crypto.py` reads and caches the decoded key. If the variable is missing, the module raises a clear error at import time rather than failing silently at encrypt/decrypt time. + +--- + +## 2. Firestore Model — `apps/api/app/models/credential.py` (new file) + +### 2.1 Document Structure + +Firestore path: `users/{uid}/credentials/{credentialId}` + +```python +@dataclass +class CredentialDocument(BaseDocument): + COLLECTION = "credentials" # subcollection name under user doc + + provider: str = "" # "anthropic" | "openai" | "google" | "custom" + name: str = "" # user-chosen display name, e.g. "My Anthropic Key" + encrypted_key: str = "" # base64-encoded AES-256-GCM blob + key_suffix: str = "" # last 4 chars of plaintext key for display +``` + +Inherits `id`, `created_at`, `updated_at` from `BaseDocument`. + +### 2.2 Subcollection Pattern + +Unlike the top-level `users` collection, credentials live as a **subcollection** under each user document. The repository must reference the parent path: `users/{uid}/credentials`. + +--- + +## 3. Credential Repository — `apps/api/app/repositories/credential.py` (new file) + +Extends `BaseRepository[CredentialDocument]` but overrides the collection reference to use the user-scoped subcollection. + +```python +class CredentialRepository: + def __init__(self, db: firestore.Client, user_id: str): + """Initialize with a Firestore client and the owning user's UID.""" + # collection_ref = db.collection("users").document(user_id).collection("credentials") + + def create(self, doc: CredentialDocument) -> CredentialDocument: ... + def get(self, credential_id: str) -> CredentialDocument | None: ... + def list(self, limit: int = 50) -> list[CredentialDocument]: ... + def update(self, credential_id: str, data: dict) -> CredentialDocument | None: ... + def delete(self, credential_id: str) -> bool: ... +``` + +The repository does **not** handle encryption — it stores/retrieves whatever it's given. Encryption is the responsibility of the route layer (or a service function) so the repository stays generic. + +--- + +## 4. API Routes — `apps/api/app/routes/keys.py` (new file) + +All endpoints require authentication (`CurrentUser` dependency). Keys are scoped to the authenticated user — no cross-user access is possible by design (subcollection path contains `user.uid`). + +### 4.1 Endpoints + +| Method | Path | Auth | Purpose | +|--------|------|------|---------| +| `POST` | `/api/keys` | Yes | Create a new API key credential | +| `GET` | `/api/keys` | Yes | List all credentials (masked — no plaintext) | +| `GET` | `/api/keys/:id` | Yes | Get a single credential (masked) | +| `PUT` | `/api/keys/:id` | Yes | Update a credential (name, key, or both) | +| `DELETE` | `/api/keys/:id` | Yes | Delete a credential (zeroes blob before removal) | + +### 4.2 Request / Response Schemas + +```python +class CreateKeyRequest(BaseModel): + provider: str # "anthropic" | "openai" | "google" | "custom" + name: str # display name + key: str # plaintext API key (only accepted on create/update, never returned) + +class UpdateKeyRequest(BaseModel): + name: str | None = None + key: str | None = None # if provided, re-encrypts + +class KeyResponse(BaseModel): + id: str + provider: str + name: str + key_hint: str # e.g. "sk-...7f3a" + created_at: str + updated_at: str +``` + +### 4.3 Endpoint Behavior + +**POST /api/keys:** +1. Validate `key` is non-empty and >= 8 characters +2. `mask_key(key)` → `key_suffix` +3. `encrypt(key)` → `encrypted_key` +4. Generate a UUID for the credential ID +5. Store `CredentialDocument` via repository +6. Return `KeyResponse` (masked) + +**GET /api/keys:** +1. List all credential documents for the user +2. Map each to `KeyResponse` using `key_suffix` — never decrypt +3. Return list + +**GET /api/keys/:id:** +1. Get credential document by ID +2. Return `KeyResponse` (masked) — or 404 + +**PUT /api/keys/:id:** +1. Get existing document — 404 if not found +2. If `key` provided: validate, re-encrypt, update `key_suffix` +3. If `name` provided: update name +4. Update document via repository +5. Return updated `KeyResponse` + +**DELETE /api/keys/:id:** +1. Get existing document — 404 if not found +2. Overwrite `encrypted_key` field with empty string (zero out the blob) +3. Delete the document +4. Return 204 + +### 4.4 Router Registration + +Add to `apps/api/app/main.py`: +```python +from app.routes import keys +app.include_router(keys.router, prefix="/api") +``` + +--- + +## 5. Configuration Changes + +### 5.1 API Config — `apps/api/app/lib/config.py` + +Add to `Settings`: +```python +credential_encryption_key: str = "" # base64-encoded 32-byte AES key +``` + +### 5.2 Environment — `apps/api/.env.example` + +Add: +``` +# 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= +``` + +### 5.3 GCP Secret Manager — `infra/terraform/modules/mcontrol/secrets.tf` + +Add a new secret resource: +```hcl +resource "google_secret_manager_secret" "credential_encryption_key" { + secret_id = "credential-encryption-key-${var.environment}" + ... +} +``` + +Add to Cloud Run environment variables in `cloud_run.tf`. + +### 5.4 Makefile + +No changes needed — the existing `.env` include and export pattern handles new variables automatically. + +--- + +## 6. API Dependencies + +### 6.1 Python Package + +Add `cryptography` to `apps/api/requirements.txt`: +``` +cryptography>=44.0.0 +``` + +This is a well-maintained, audited library that provides AES-GCM primitives. No Firebase or Google-specific encryption SDK is needed. + +--- + +## 7. Desktop Changes — `apps/desktop/` + +### 7.1 API Client — `src/lib/api.ts` + +Add authenticated API functions: + +```typescript +// Credential types +interface Credential { + id: string; + provider: string; + name: string; + key_hint: string; + created_at: string; + updated_at: string; +} + +// CRUD operations +async function createKey(provider: string, name: string, key: string): Promise +async function listKeys(): Promise +async function getKey(id: string): Promise +async function updateKey(id: string, data: { name?: string; key?: string }): Promise +async function deleteKey(id: string): Promise +``` + +All use `fetchWithAuth()` which injects the Bearer token. + +### 7.2 UI — Not in scope + +The acceptance criteria focus on the backend storage and API layer. A full credentials management UI (settings page with add/edit/delete forms) is a separate task. However, the API client functions above should be implemented so the UI task only needs to wire up components. + +--- + +## 8. Shared Schema Update — `packages/shared/` + +### 8.1 TypeScript Schema — `packages/shared/src/schemas.ts` + +The existing `Credential` schema needs no `key` field (keys are never returned in full). Verify the existing schema aligns with `KeyResponse`: + +```typescript +// Existing fields: id, name, type, provider, createdAt, updatedAt +// Add if missing: +export const Credential = z.object({ + id: UUID, + name: z.string().min(1).max(255), + provider: Provider, + keyHint: z.string(), // add: masked key suffix + createdAt: DateTimeString, + updatedAt: DateTimeString, +}); +``` + +Remove `type` field (credential type like "api_key" | "oauth" is not needed for this task — all credentials are API keys). Or keep it if other credential types are planned soon. + +### 8.2 Pydantic Codegen + +Run `pnpm --filter @mcontrol/shared generate:pydantic` after schema changes. + +--- + +## 9. Security Considerations + +### 9.1 Encryption Key Rotation + +Not in scope for this task. Future work: support multiple key versions (store key version ID alongside encrypted blob), decrypt with old key, re-encrypt with new key. + +### 9.2 Python String Zeroing Limitation + +Python strings are immutable — true zeroing is not possible at the language level. The `zero_string` helper does a best-effort `ctypes` overwrite of the string buffer, but this is acknowledged as a mitigation, not a guarantee. The key mitigation is that plaintext keys exist in memory only for the duration of a single request (encrypt on store, decrypt on LLM call). + +### 9.3 Logging + +Ensure no route, middleware, or exception handler logs request bodies for key endpoints. FastAPI's default exception handler should not leak `CreateKeyRequest.key` in error responses. Use `key: SecretStr` from Pydantic if additional safety is desired. + +### 9.4 Transport Security + +All deployed environments use HTTPS (Cloud Run enforces it). Local dev uses HTTP but is localhost-only. + +--- + +## 10. Tests — `apps/api/tests/test_keys.py` (new file) + +### 10.1 Unit Tests (no Firestore required) + +| Test | What it verifies | +|------|-----------------| +| `test_encrypt_decrypt_roundtrip` | `decrypt(encrypt(key)) == key` for various key formats | +| `test_encrypt_produces_unique_ciphertext` | Two encryptions of the same plaintext produce different blobs (unique nonce) | +| `test_mask_key_format` | `mask_key("sk-abc123def456")` → `"sk-...f456"` | +| `test_mask_key_short` | Short keys (< 8 chars) are rejected | +| `test_decrypt_wrong_key_fails` | Decrypting with a different encryption key raises error | +| `test_decrypt_corrupted_blob_fails` | Tampered ciphertext raises error | + +### 10.2 Integration Tests (with Firestore emulator) + +| Test | What it verifies | +|------|-----------------| +| `test_create_key` | POST returns 201 with masked `key_hint`, stores encrypted blob | +| `test_list_keys_masked` | GET returns list with `key_hint`, no plaintext key field | +| `test_get_key_masked` | GET /:id returns single credential, masked | +| `test_update_key` | PUT updates name and/or re-encrypts key | +| `test_delete_key` | DELETE returns 204, subsequent GET returns 404 | +| `test_create_key_empty_rejected` | POST with empty key returns 422 | +| `test_cross_user_isolation` | User A cannot see/modify User B's keys | +| `test_unauthenticated_rejected` | All endpoints return 401 without Bearer token | + +--- + +## File Summary + +### New Files + +| File | Purpose | +|------|---------| +| `apps/api/app/lib/crypto.py` | AES-256-GCM encrypt/decrypt, key masking | +| `apps/api/app/models/credential.py` | Firestore document model | +| `apps/api/app/repositories/credential.py` | User-scoped subcollection CRUD | +| `apps/api/app/routes/keys.py` | REST endpoints for credential management | +| `apps/api/tests/test_keys.py` | Unit + integration tests | + +### Modified Files + +| File | Change | +|------|--------| +| `apps/api/app/lib/config.py` | Add `credential_encryption_key` setting | +| `apps/api/app/main.py` | Register keys router | +| `apps/api/.env.example` | Add `CREDENTIAL_ENCRYPTION_KEY` | +| `apps/api/requirements.txt` | Add `cryptography` | +| `apps/desktop/src/lib/api.ts` | Add credential CRUD API functions | +| `packages/shared/src/schemas.ts` | Add `keyHint` to Credential schema | +| `infra/terraform/modules/mcontrol/secrets.tf` | Add encryption key secret | +| `infra/terraform/modules/mcontrol/cloud_run.tf` | Inject encryption key env var | + +--- + +## Definition of Done + +1. **Encryption works:** `encrypt → store → retrieve → decrypt` round-trip produces the original key +2. **Keys never stored in plaintext:** Firestore documents contain only the AES-256-GCM encrypted blob +3. **GET never leaks keys:** All GET responses return `key_hint` (e.g. `sk-...7f3a`), never the full key +4. **Per-user isolation:** User A cannot access User B's credentials (enforced by subcollection path) +5. **Delete zeroes blob:** Deleting a key overwrites the encrypted field before removing the document +6. **Validation enforced:** Empty and malformed keys are rejected at the API layer +7. **All checks pass:** `make lint`, `make typecheck`, `make test` pass with zero errors + +--- + +## What NOT to build + +- Credentials management UI (settings page, forms) — separate task +- Encryption key rotation mechanism +- Client-side encryption or OS keychain integration +- Rate limiting on key endpoints +- Key usage tracking or audit logging +- Support for OAuth or service account credential types (API keys only for now) +- Automatic key validation against provider APIs