Skip to content
Closed
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
9 changes: 8 additions & 1 deletion app/config.py
Original file line number Diff line number Diff line change
Expand Up @@ -52,9 +52,16 @@ class Settings(BaseSettings):
port: int = 8000
log_level: str = "info"

# ── Encryption (auto-generated on first run if empty) ──
# ── Encryption ──
# NOTE: credential_encryption_key is NOT auto-generated. If empty, the
# app seals provider keys with a publicly-known dev key (with a loud
# warning), and packages.db.guards refuses to boot once real
# credentials are at stake. Generate one: `openssl rand -hex 32`.
credential_encryption_key: str = ""
api_key_pepper: str = ""
# Explicit opt-out from the startup guard that refuses to run with the
# publicly-known dev encryption key when real credentials are at stake.
allow_insecure_dev_key: bool = False

# ── Provider keys via env (alternative to UI-stored keys) ──
# Keep in sync with `_PROVIDERS_FROM_ENV` above. Pydantic-settings reads
Expand Down
22 changes: 19 additions & 3 deletions app/main.py
Original file line number Diff line number Diff line change
Expand Up @@ -23,7 +23,7 @@ async def lifespan(app: FastAPI) -> AsyncGenerator[None, None]:

from app.config import get_settings
from packages.db import session as session_mod
from packages.db.engine import dispose_engine, get_engine
from packages.db.engine import dispose_engine, get_engine, redacted_url
from packages.db.models.base import Base

settings = get_settings()
Expand All @@ -40,21 +40,37 @@ async def lifespan(app: FastAPI) -> AsyncGenerator[None, None]:
),
)
log = structlog.get_logger()
log.info("lite_starting", database_url=settings.database_url)
# Redact: on the documented Postgres path the raw URL carries the DB
# password, and structured logs are retained by hosted aggregators.
log.info("lite_starting", database_url=redacted_url(settings.database_url))

engine = get_engine(settings.database_url)

async with engine.begin() as conn:
await conn.run_sync(Base.metadata.create_all)

# Fail closed before any traffic can be served: refuse to boot when
# provider credentials are (or would be) sealed with the publicly-known
# dev encryption key. Runs after create_all so a fresh database's empty
# provider_keys table counts as "no credentials at risk".
from packages.db.guards import assert_credential_encryption_ready

await assert_credential_encryption_ready(
make_session=async_sessionmaker(engine, expire_on_commit=False),
database_url=settings.database_url,
allow_insecure_dev_key=settings.allow_insecure_dev_key,
)

session_mod._session_factory = async_sessionmaker(engine, expire_on_commit=False)

from app.seed import seed_initial_state

async with session_mod._session_factory() as s:
seed = await seed_initial_state(s)
if seed.created and seed.api_key:
log.info("seed_complete", api_key=seed.api_key)
# No key material in the structured event: logs are retained by
# aggregators. The print() below is the one-time delivery channel.
log.info("seed_complete", workspace_id=seed.workspace_id)
try:
print(f"\n ✓ orcarouter-lite ready. API key: {seed.api_key}\n")
except UnicodeEncodeError:
Expand Down
29 changes: 29 additions & 0 deletions app/routes/chat.py
Original file line number Diff line number Diff line change
Expand Up @@ -31,6 +31,7 @@
from app.protocols.sse import AdapterError
from app.quality_scores import resolve_model_metrics
from app.schemas import ChatCompletionRequest
from packages.auth.spend import budget_exceeded, get_lifetime_spend_microcents
from packages.auth.types import KeyContext
from packages.db.models.request_log import RequestLog
from packages.litellm_adapter.catalog import CATALOG, CATALOG_BY_ID
Expand Down Expand Up @@ -306,6 +307,34 @@ async def execute_chat(
detail=f"Model '{body.model}' is not allowed for this API key",
)

# Budget enforcement: `budget_limit_cents` is a lifetime cap on this
# key's total spend (sum of `cost_microcents` for all non-deleted
# request-log rows, including streaming 499/503 rows that already
# incurred provider billing). Checked before any routing, resolution,
# or cache work so an exhausted key costs the operator nothing — no
# upstream attempt, no cache fill.
#
# NOTE: This is a best-effort soft limit, not a hard atomic cap.
# Spend is read before the request and the current request's cost is
# only written after the response/stream completes. N concurrent
# requests from the same budgeted key all observe the same
# pre-request total and may all pass the check, exceeding the cap by
# up to N× per-request cost in a burst. A hard cap would require a
# reservation/claim or row-level lock before dispatch; the current
# design trades strictness for simplicity and avoids holding a DB
# transaction across the upstream call. See spend.py for aggregation
# semantics.
if kc.budget_limit_cents is not None:
spend = await get_lifetime_spend_microcents(db, str(kc.key_id))
if budget_exceeded(spend, kc.budget_limit_cents):
raise HTTPException(
status_code=429,
detail=(
"API key budget exhausted "
f"({spend} of {kc.budget_limit_cents * 10_000} microcents spent)."
),
)

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🟡 P2 Make budget enforcement atomic against concurrent requests

The budget check in chat.py (lines 275-284) reads the key's historical spend and then serves the request; the current request's cost is only written to requests_log afterwards (after the response for non-stream, at stream end via _finalize for streaming). There is no atomic claim, reservation, or post-completion clamp. N concurrent requests from the same budgeted key all observe the same pre-budget total and all pass budget_exceeded, so the lifetime cap can be exceeded by up to N× the per-request cost in a single burst. The commit's stated guarantee ("an exhausted key costs the operator nothing — no upstream attempt") holds only for sequentially-arriving requests; under concurrency the cap is soft. If the intended contract is a hard cap, this needs an atomic pre-check (e.g. reserve/claim the request's projected cost before dispatching) or a documented soft-limit semantics.


client = await router_cache.get_router(db)

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🟠 P1 Make the budget check atomic (reserve/claim or lock) so concurrent requests cannot overshoot the cap

The new enforcement is a plain check-then-act with no compare-and-swap: get_lifetime_spend_microcents(db, key_id) reads the sum of committed request-log rows, and only after the upstream call completes is the current request's cost written to requests_log (blocking path lines 746-750; streaming path _finalize lines 567-577, in a separate session). The check and the accounting write share no transaction, no lock, and no reservation. N concurrent requests from the same budgeted key all read the same pre-request total, all pass budget_exceeded(), all hit the provider, and the sum of their post-hoc cost rows exceeds budget_limit_cents by up to N× per-request cost. Since the API presents the check as "API key budget exhausted" (a hard lifetime cap on the operator's spend), the operator is billed beyond the limit they set — an accounted quantity limited wrongly. The code comment documents this as an accepted soft limit, which is why confidence is medium, but the consequence (unbounded-in-N overspend against a stated cap) is real and is exactly the read-modify-write-without-CAS pattern. Fix: reserve the expected cost before dispatch (e.g. an atomic pending-spend row per request, or SELECT ... FOR UPDATE on the api_keys row for the duration of the check+reserve, reconciled after completion) so the cap cannot be exceeded by concurrent admission.

raw_strategy = getattr(client, "strategy", None)
strategy = raw_strategy if isinstance(raw_strategy, str) and raw_strategy else "balanced"
Expand Down
53 changes: 48 additions & 5 deletions app/routes/keys.py
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,7 @@
from datetime import datetime, timezone

from fastapi import APIRouter, Depends, HTTPException, Response
from pydantic import BaseModel
from pydantic import BaseModel, Field
from sqlalchemy import select
from sqlalchemy.ext.asyncio import AsyncSession

Expand All @@ -20,16 +20,46 @@

class CreateKey(BaseModel):
name: str
# Optional restrictions for child keys. Only reachable by unrestricted
# callers (require_unrestricted above), so a restricted key can never
# mint a sibling with looser limits than its own — it can't mint at all.
model_allowlist: list[str] | None = None
budget_limit_cents: int | None = Field(default=None, gt=0)


def require_unrestricted(kc: KeyContext) -> None:
"""Key management is reserved for unrestricted keys.

A key that carries any restriction (`model_allowlist` or
`budget_limit_cents`) must not be able to mint, list, or revoke other
keys — otherwise it could create a sibling with no restrictions and
trivially bypass its own allowlist/budget. Unrestricted keys already
hold the maximum privilege this single-workspace edition exposes
(same trust level as PUT /v1/providers/*), so denying restricted keys
here grants nothing to anyone; it only closes the escalation path.
"""
if kc.model_allowlist is not None or kc.budget_limit_cents is not None:
raise HTTPException(
status_code=403,
detail=(
"Restricted API keys cannot manage keys. "
"Use an unrestricted key."
),
)


@router.get("")
async def list_keys(
_kc: KeyContext = Depends(get_key_context),
kc: KeyContext = Depends(get_key_context),
db: AsyncSession = Depends(get_db),
) -> dict:
require_unrestricted(kc)
rows = (
await db.execute(
select(ApiKey).where(ApiKey.is_deleted == 0).order_by(ApiKey.created_at)
select(ApiKey).where(
ApiKey.workspace_id == kc.workspace_id,
ApiKey.is_deleted == 0,
).order_by(ApiKey.created_at)
)
).scalars().all()
return {
Expand All @@ -54,12 +84,15 @@ async def create_key(
kc: KeyContext = Depends(get_key_context),
db: AsyncSession = Depends(get_db),
) -> dict:
require_unrestricted(kc)
full_key, key_hash, key_prefix = generate_api_key()
row = ApiKey(
workspace_id=kc.workspace_id,
name=body.name,
key_hash=key_hash,
key_prefix=key_prefix,
model_allowlist=body.model_allowlist,
budget_limit_cents=body.budget_limit_cents,
)
db.add(row)
await db.commit()
Expand All @@ -70,18 +103,28 @@ async def create_key(
"name": row.name,
"key_prefix": row.key_prefix,
"api_key": full_key, # plaintext shown ONCE
"model_allowlist": row.model_allowlist,
"budget_limit_cents": row.budget_limit_cents,
}


@router.delete("/{key_id}", status_code=204)
async def revoke_key(
key_id: str,
_kc: KeyContext = Depends(get_key_context),
kc: KeyContext = Depends(get_key_context),
db: AsyncSession = Depends(get_db),
) -> Response:
require_unrestricted(kc)
row = (
await db.execute(
select(ApiKey).where(ApiKey.id == key_id, ApiKey.is_deleted == 0)
select(ApiKey).where(
ApiKey.id == key_id,
# Workspace scoping: without this, any key could revoke any
# other workspace's keys (the write path has always been
# scoped; the read/delete paths were not).
ApiKey.workspace_id == kc.workspace_id,
ApiKey.is_deleted == 0,
)
)
).scalar_one_or_none()
if row is None:
Expand Down
116 changes: 102 additions & 14 deletions packages/auth/encryption.py
Original file line number Diff line number Diff line change
Expand Up @@ -5,21 +5,69 @@
test fixtures that set the env var directly.

If neither yields a key, derives a deterministic dev key from a fixed seed
so local development doesn't require any setup. **In production, set a real
64-char hex string** — the dev fallback is publicly known via the source
code, so anyone with read access to the SQLite file could decrypt provider
keys with it.
so local development doesn't require any setup. The dev fallback is
publicly known via the source code, so anyone with read access to the
SQLite file could decrypt provider keys with it:

- Every use logs a prominent WARNING (`insecure_dev_encryption_key`).
- `packages.db.guards.assert_credential_encryption_ready` fail-closes at
startup when the fallback would protect real credentials (existing
provider rows, or any non-SQLite database) unless
ORCA_ALLOW_INSECURE_DEV_KEY=1 is set explicitly.

Ciphertext format:

- v1 (current): ``b"\\x01" + nonce(12) + ciphertext+tag``
- legacy: ``nonce(12) + ciphertext+tag`` (no version byte)

Decrypt auto-detects. A legacy blob whose first nonce byte happens to be
``0x01`` (~0.4% of legacy blobs) is attempted as v1 first and falls back
to the legacy parse when authentication fails, so upgrades never brick
stored credentials.
"""

from __future__ import annotations

import hashlib
import logging
import os

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

logger = logging.getLogger("orca.encryption")

VERSION_BYTE = b"\x01"
_NONCE_LEN = 12
_TAG_LEN = 16

_dev_fallback_warned = False


def _warn_dev_fallback_once() -> None:
global _dev_fallback_warned
if _dev_fallback_warned:
return
_dev_fallback_warned = True
logger.warning(
"insecure_dev_encryption_key: CREDENTIAL_ENCRYPTION_KEY is not set; "
"provider credentials are being sealed with a PUBLICLY-KNOWN dev "
"key. Anyone with read access to the database can decrypt them. "
"Generate one with `openssl rand -hex 32` (or set "
"ORCA_ALLOW_INSECURE_DEV_KEY=1 to silence this check)."
)


def _get_encryption_key() -> bytes:
key, _source = _resolve_key_material()
return key


def _resolve_key_material() -> tuple[bytes, str]:
"""Return (key_bytes, source) where source names how the key was obtained.

Sources: "config" (Settings/.env), "env" (os.environ), "dev-fallback".
"""
# Prefer Settings (which loads .env) over raw os.environ, because
# pydantic-settings does NOT propagate .env values into os.environ.
# Without this lookup, a user who follows the README and writes
Expand All @@ -33,27 +81,67 @@ def _get_encryption_key() -> bytes:
# Settings may not be importable in some isolated test contexts;
# fall through to env-only behavior.
pass
if not key_hex:
key_hex = os.environ.get("CREDENTIAL_ENCRYPTION_KEY", "")
if key_hex:
try:
raw = bytes.fromhex(key_hex)
if len(raw) >= 32:
return raw[:32]
return raw[:32], "config"
except ValueError:
pass
return hashlib.sha256(key_hex.encode()).digest(), "config"
key_hex = os.environ.get("CREDENTIAL_ENCRYPTION_KEY", "")
if key_hex:
try:
raw = bytes.fromhex(key_hex)
if len(raw) >= 32:
return raw[:32], "env"
except ValueError:
pass
return hashlib.sha256(key_hex.encode()).digest()
return hashlib.sha256(key_hex.encode()).digest(), "env"
# Dev fallback so test fixtures and `docker compose up` Just Work.
return hashlib.sha256(b"orcarouter-lite-dev-key").digest()
_warn_dev_fallback_once()
return hashlib.sha256(b"orcarouter-lite-dev-key").digest(), "dev-fallback"


def is_using_insecure_dev_key() -> bool:
try:
return _resolve_key_material()[1] == "dev-fallback"
except Exception:
return False


def encrypt_credential(plaintext: str) -> bytes:
aes = AESGCM(_get_encryption_key())
nonce = os.urandom(12)
return nonce + aes.encrypt(nonce, plaintext.encode("utf-8"), None)
nonce = os.urandom(_NONCE_LEN)
return VERSION_BYTE + nonce + aes.encrypt(nonce, plaintext.encode("utf-8"), None)


def decrypt_credential(blob: bytes) -> str:
aes = AESGCM(_get_encryption_key())
nonce, ciphertext = blob[:12], blob[12:]
return aes.decrypt(nonce, ciphertext, None).decode("utf-8")
key = _get_encryption_key()
aes = AESGCM(key)

if blob[:1] == VERSION_BYTE and len(blob) >= 1 + _NONCE_LEN + _TAG_LEN:
try:
return aes.decrypt(
blob[1:1 + _NONCE_LEN], blob[1 + _NONCE_LEN:], None
).decode("utf-8")
except InvalidTag:
# Could be a LEGACY blob whose first nonce byte happens to be
# 0x01 (~0.4%). Fall through and try the unversioned layout
# before giving up.
pass
except ValueError as e:
# Malformed v1 blob (should be rare due to length check) — treat
# as authentication failure rather than leaking ValueError.
raise InvalidTag(str(e)) from e

# Legacy unversioned blob: nonce(12) || ciphertext+tag.
nonce, ciphertext = blob[:_NONCE_LEN], blob[_NONCE_LEN:]
try:
return aes.decrypt(nonce, ciphertext, None).decode("utf-8")
except ValueError as e:
# cryptography raises ValueError for malformed inputs (nonce must be
# 12 bytes / data must be at least 16 bytes) rather than InvalidTag,
# which is only for GCM tag mismatch. Normalize so callers and the
# truncated-blob test have a single exception type to handle.
raise InvalidTag(str(e)) from e
Loading