diff --git a/.env.example b/.env.example index ffdc314..33edbcf 100644 --- a/.env.example +++ b/.env.example @@ -12,7 +12,6 @@ ENABLE_GOLDEN_RECORDS=false ENABLE_EXTERNAL_CONNECTIONS=true ALLOW_PRIVATE_DATABASE_HOSTS=false CONNECTION_ENCRYPTION_KEY= -AUTH_SIGNING_KEY= DATABASE_CONNECTION_TIMEOUT_SECONDS=5 DATABASE_STATEMENT_TIMEOUT_MS=10000 DATABASE_MAX_RESULT_ROWS=500 diff --git a/README.md b/README.md index 91610a0..7843cc6 100644 --- a/README.md +++ b/README.md @@ -112,7 +112,7 @@ For the default Compose stack, optional vector features are off so schema intros | `EMBEDDING_PROVIDER`, `EMBEDDING_MODEL` | Local semantic-retrieval configuration | | `ENABLE_SCHEMA_RAG`, `ENABLE_GOLDEN_RECORDS` | Optional embedding features | | `ENABLE_EXTERNAL_CONNECTIONS`, `ALLOW_PRIVATE_DATABASE_HOSTS` | BYOD and SSRF policy flags | -| `CONNECTION_ENCRYPTION_KEY`, `AUTH_SIGNING_KEY` | Backend-only Fernet and session signing secrets | +| `CONNECTION_ENCRYPTION_KEY` | Backend-only Fernet key; session signing is derived from it with domain separation | | `DATABASE_*` | Connect timeout, statement timeout, SQL length, and result caps | | `SCHEMA_*_TABLE_LIMIT` | Full-context and retrieval bounds | | `NEXT_PUBLIC_API_URL` | Browser-visible API `/api/v1` URL | diff --git a/apps/api/.env.example b/apps/api/.env.example index 291ae1f..396de61 100644 --- a/apps/api/.env.example +++ b/apps/api/.env.example @@ -15,7 +15,6 @@ ENABLE_GOLDEN_RECORDS=false ENABLE_EXTERNAL_CONNECTIONS=false ALLOW_PRIVATE_DATABASE_HOSTS=false CONNECTION_ENCRYPTION_KEY= -AUTH_SIGNING_KEY= DATABASE_CONNECTION_TIMEOUT_SECONDS=5 DATABASE_STATEMENT_TIMEOUT_MS=10000 DATABASE_MAX_RESULT_ROWS=500 diff --git a/apps/api/README.md b/apps/api/README.md index 96d315c..99c9804 100644 --- a/apps/api/README.md +++ b/apps/api/README.md @@ -28,7 +28,7 @@ uvicorn app.main:app --reload --port 8000 Production startup never creates tables. Alembic creates application tables and pgvector. The demo seed is idempotent. Use `../../scripts/create_readonly_role.sql` as a reviewed template and run query traffic with that role. -Saved customer credentials require `CONNECTION_ENCRYPTION_KEY` (a Fernet key). Signed workspace sessions require a separate `AUTH_SIGNING_KEY`. Generate them with the commands in `../../docs/deployment.md`. BYOD accepts URL or structured PostgreSQL input, requires SSL, blocks unsafe networks by default, and stores normalized schema snapshots without row data. +Saved customer credentials require `CONNECTION_ENCRYPTION_KEY` (a Fernet key). Workspace-session signing uses a domain-separated key derived from that Fernet key, so no second signing secret is required. Generate the key with the command in `../../docs/deployment.md`. BYOD accepts URL or structured PostgreSQL input, requires SSL, blocks unsafe networks by default, and stores normalized schema snapshots without row data. ## Safety model diff --git a/apps/api/app/auth/dependencies.py b/apps/api/app/auth/dependencies.py index bbf572c..c806a92 100644 --- a/apps/api/app/auth/dependencies.py +++ b/apps/api/app/auth/dependencies.py @@ -1,4 +1,5 @@ import base64 +import binascii import hashlib import hmac import json @@ -13,9 +14,17 @@ def _key() -> bytes: - if not settings.AUTH_SIGNING_KEY: - raise DependencyError("AUTH_SIGNING_KEY is required for saved connection sessions") - return settings.AUTH_SIGNING_KEY.encode() + if not settings.CONNECTION_ENCRYPTION_KEY: + raise DependencyError("CONNECTION_ENCRYPTION_KEY is required for saved connection sessions") + try: + key_material = base64.b64decode( + settings.CONNECTION_ENCRYPTION_KEY.encode(), altchars=b"-_", validate=True + ) + except (binascii.Error, ValueError) as exc: + raise DependencyError("CONNECTION_ENCRYPTION_KEY is invalid") from exc + if len(key_material) != 32: + raise DependencyError("CONNECTION_ENCRYPTION_KEY is invalid") + return hmac.new(key_material, b"querymindai/workspace-session/v1", hashlib.sha256).digest() def issue_session() -> tuple[str, str, int]: diff --git a/apps/api/app/core/config.py b/apps/api/app/core/config.py index d1c31b4..f8f2190 100644 --- a/apps/api/app/core/config.py +++ b/apps/api/app/core/config.py @@ -23,7 +23,6 @@ class Settings(BaseSettings): ENABLE_EXTERNAL_CONNECTIONS: bool = False ALLOW_PRIVATE_DATABASE_HOSTS: bool = False CONNECTION_ENCRYPTION_KEY: str | None = None - AUTH_SIGNING_KEY: str | None = None AUTH_SESSION_TTL_SECONDS: int = Field(2592000, ge=300, le=31536000) DATABASE_CONNECTION_TIMEOUT_SECONDS: int = Field(5, ge=1, le=30) DATABASE_STATEMENT_TIMEOUT_MS: int = Field(10000, ge=100, le=300000) diff --git a/apps/api/tests/test_auth.py b/apps/api/tests/test_auth.py index a898697..cb3977c 100644 --- a/apps/api/tests/test_auth.py +++ b/apps/api/tests/test_auth.py @@ -1,12 +1,19 @@ import pytest +from cryptography.fernet import Fernet from app.auth.dependencies import get_current_user_id, issue_session from app.core.config import settings -from app.core.exceptions import AuthenticationError +from app.core.exceptions import AuthenticationError, DependencyError def test_signed_session_subject_cannot_be_forged(monkeypatch): - monkeypatch.setattr(settings,"AUTH_SIGNING_KEY","test-signing-key-not-for-production") + monkeypatch.setattr(settings, "CONNECTION_ENCRYPTION_KEY", Fernet.generate_key().decode()) token,user_id,_=issue_session() assert get_current_user_id(f"Bearer {token}")==user_id with pytest.raises(AuthenticationError): get_current_user_id(f"Bearer {token}tampered") + + +def test_session_requires_connection_encryption_key(monkeypatch): + monkeypatch.setattr(settings, "CONNECTION_ENCRYPTION_KEY", None) + with pytest.raises(DependencyError, match="CONNECTION_ENCRYPTION_KEY"): + issue_session() diff --git a/docker-compose.yml b/docker-compose.yml index fff069d..474f6fb 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -32,7 +32,6 @@ services: ENABLE_EXTERNAL_CONNECTIONS: ${ENABLE_EXTERNAL_CONNECTIONS:-true} ALLOW_PRIVATE_DATABASE_HOSTS: ${ALLOW_PRIVATE_DATABASE_HOSTS:-false} CONNECTION_ENCRYPTION_KEY: ${CONNECTION_ENCRYPTION_KEY:-} - AUTH_SIGNING_KEY: ${AUTH_SIGNING_KEY:-} DATABASE_CONNECTION_TIMEOUT_SECONDS: ${DATABASE_CONNECTION_TIMEOUT_SECONDS:-5} DATABASE_STATEMENT_TIMEOUT_MS: ${DATABASE_STATEMENT_TIMEOUT_MS:-10000} DATABASE_MAX_RESULT_ROWS: ${DATABASE_MAX_RESULT_ROWS:-500} diff --git a/docs/deployment.md b/docs/deployment.md index dc65d0a..f43b359 100644 --- a/docs/deployment.md +++ b/docs/deployment.md @@ -4,16 +4,15 @@ ```bash cp .env.example .env -# Generate backend-only secrets and paste them into .env: +# Generate the backend-only credential encryption key and paste it into .env: python -c "from cryptography.fernet import Fernet; print(Fernet.generate_key().decode())" -python -c "import secrets; print(secrets.token_urlsafe(48))" docker compose up --build # Optional local provider: docker compose --profile ollama up --build docker compose exec ollama ollama pull sqlcoder ``` -Use the first generated value for `CONNECTION_ENCRYPTION_KEY` and the second for `AUTH_SIGNING_KEY`. Open web at `http://localhost:4028`, API docs at `http://localhost:8000/docs`, and check `/health` then `/ready`. Configure a provider for generation. BYOD databases must be publicly reachable PostgreSQL on port 5432 with SSL and a read-only role. +Use the generated value for `CONNECTION_ENCRYPTION_KEY`. Session signing is derived from this key, so no separate signing secret is needed. Open web at `http://localhost:4028`, API docs at `http://localhost:8000/docs`, and check `/health` then `/ready`. Configure a provider for generation. BYOD databases must be publicly reachable PostgreSQL on port 5432 with SSL and a read-only role. ## Supabase application database @@ -33,7 +32,7 @@ Enter the complete value only in Render's secret `DATABASE_URL` field. Never com 1. Push this repository to GitHub. In Render choose **New → Blueprint**, connect that repository, and select `render.yaml`. 2. Review creation or update of `querymind-api` and `querymind-web`. The Blueprint intentionally does not provision a Render PostgreSQL database. -3. Enter backend secrets in the non-synced fields: Supabase session-pooler `DATABASE_URL`, Groq `LLM_API_KEY`, a Fernet `CONNECTION_ENCRYPTION_KEY`, and an independent random `AUTH_SIGNING_KEY`. Never reuse or commit these values. +3. Enter backend secrets in the non-synced fields: Supabase session-pooler `DATABASE_URL`, Groq `LLM_API_KEY`, and a Fernet `CONNECTION_ENCRYPTION_KEY`. Never reuse or commit these values. 4. Set `CORS_ALLOW_ORIGINS` on the API to the final HTTPS web origin, without a trailing slash. 5. Deploy the API. On the free tier, the API start command runs `alembic upgrade head` before starting Uvicorn because Render does not support pre-deploy commands for free services. Confirm `https:///health` and `/ready`. 6. Set `NEXT_PUBLIC_API_URL` on `querymind-web` to `https:///api/v1`, then trigger a clean frontend deploy. Render does not provide a supported Blueprint interpolation from another web service’s eventual public hostname into a Next.js build variable; this manual build-time step is required. diff --git a/docs/local-development.md b/docs/local-development.md index cb716c0..1a464bf 100644 --- a/docs/local-development.md +++ b/docs/local-development.md @@ -23,14 +23,13 @@ cd QueryMindAI cp .env.example .env ``` -Generate two independent backend secrets: +Generate the backend credential-encryption key: ```bash python -c "from cryptography.fernet import Fernet; print(Fernet.generate_key().decode())" -python -c "import secrets; print(secrets.token_urlsafe(48))" ``` -Put the first value in `CONNECTION_ENCRYPTION_KEY` and the second in `AUTH_SIGNING_KEY` in your local `.env`. Never commit that file. +Put the value in `CONNECTION_ENCRYPTION_KEY` in your local `.env`. Workspace-session signing is derived from it, so no second signing secret is needed. Never commit that file. ### Option A: local Ollama @@ -107,7 +106,7 @@ alembic upgrade head uvicorn app.main:app --reload --port 8000 ``` -At minimum, set the application `DATABASE_URL`, `ENABLE_EXTERNAL_CONNECTIONS=true`, and the two generated `CONNECTION_ENCRYPTION_KEY` and `AUTH_SIGNING_KEY` values in `apps/api/.env`. Keep `CORS_ALLOW_ORIGINS=http://localhost:4028` for the default frontend port. +At minimum, set the application `DATABASE_URL`, `ENABLE_EXTERNAL_CONNECTIONS=true`, and the generated `CONNECTION_ENCRYPTION_KEY` value in `apps/api/.env`. Keep `CORS_ALLOW_ORIGINS=http://localhost:4028` for the default frontend port. In another terminal: @@ -123,7 +122,7 @@ The browser API URL must include `/api/v1`. `CORS_ALLOW_ORIGINS` on the API must ## Troubleshooting - **Connections are disabled:** set `ENABLE_EXTERNAL_CONNECTIONS=true` in the API environment and restart it. -- **Encryption/signing configuration error:** provide valid, different `CONNECTION_ENCRYPTION_KEY` and `AUTH_SIGNING_KEY` values. +- **Encryption/signing configuration error:** provide a valid Fernet `CONNECTION_ENCRYPTION_KEY` value. - **Host rejected:** public mode rejects localhost, private, link-local, reserved, and metadata IP ranges. Use a public hostname or opt into private hosts only in a trusted local environment. - **Browser reports a network or CORS error:** verify `NEXT_PUBLIC_API_URL`, `CORS_ALLOW_ORIGINS`, and `/health`. Next.js public variables are embedded at build time, so rebuild the web image after changing the API URL. - **Generation fails but schema browsing works:** verify the LLM provider URL, model, and backend-only key. Database credentials and LLM keys must never be added to `NEXT_PUBLIC_*` variables. diff --git a/docs/security.md b/docs/security.md index 35c6e3d..c491796 100644 --- a/docs/security.md +++ b/docs/security.md @@ -4,7 +4,7 @@ PostgreSQL credentials are serialized and encrypted with Fernet authenticated encryption using backend-only `CONNECTION_ENCRYPTION_KEY`. Responses contain only host, database, username, SSL mode, and status; passwords, decrypted configuration, and raw URLs are never returned or logged. Deletion cascades catalog snapshots, drafts, and BYOD history and explicitly removes connection-keyed verified examples. Audit records retain only identifiers and safe status metadata. -Application encryption is not an enterprise vault. The entire dataset must be re-encrypted to rotate the key; losing the key makes credentials unrecoverable. Mature deployments should use a managed secret store/KMS. `AUTH_SIGNING_KEY` signs browser-bound anonymous workspace sessions. These sessions are not account authentication, recovery, organization membership, MFA, or revocation infrastructure; use an external identity provider for a true multi-user deployment. +Application encryption is not an enterprise vault. The entire dataset must be re-encrypted to rotate the key; losing the key makes credentials unrecoverable. Mature deployments should use a managed secret store/KMS. Browser-bound anonymous workspace sessions are signed with a domain-separated HMAC key derived from `CONNECTION_ENCRYPTION_KEY`; the derived key is never persisted. Rotating the encryption key therefore also invalidates existing sessions. These sessions are not account authentication, recovery, organization membership, MFA, or revocation infrastructure; use an external identity provider for a true multi-user deployment. ## Network and SSRF controls diff --git a/render.yaml b/render.yaml index 0d6d9af..2f55061 100644 --- a/render.yaml +++ b/render.yaml @@ -26,8 +26,6 @@ services: sync: false - key: CONNECTION_ENCRYPTION_KEY sync: false - - key: AUTH_SIGNING_KEY - sync: false - key: DATABASE_CONNECTION_TIMEOUT_SECONDS value: "5" - key: DATABASE_STATEMENT_TIMEOUT_MS