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
13 changes: 10 additions & 3 deletions .env.example
Original file line number Diff line number Diff line change
Expand Up @@ -13,11 +13,18 @@ LOG_LEVEL=info
# its stale cache. Without it, siblings serve stale until TTL.
# REDIS_URL=redis://localhost:6379/0

# ── Encryption (auto-generated on first run if empty) ──
# Once generated, set these explicitly so DB-stored provider
# keys remain decryptable across restarts.
# ── Encryption ──
# CREDENTIAL_ENCRYPTION_KEY is REQUIRED (not auto-generated). Generate one with
# `openssl rand -hex 32` and set it here before first run. The app refuses to
# boot (fail-closed) on any non-SQLite database — or as soon as provider keys
# exist — if this is left empty, because it would otherwise seal real
# credentials with a publicly-known development key.
# CREDENTIAL_ENCRYPTION_KEY= # 64 hex chars (32 bytes)
# API_KEY_PEPPER= # 32+ char string
#
# Opt-out for the dev key (SQLite dev installs only). Setting this to 1 lets the
# app boot with the insecure public dev key. Never use in production.
# ORCA_ALLOW_INSECURE_DEV_KEY=1

# ── Provider keys (BYOK) ──────────────────────────────
# Set the provider keys you want to route through. Either
Expand Down
12 changes: 11 additions & 1 deletion app/config.py
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@

from functools import lru_cache

from pydantic import Field
from pydantic_settings import BaseSettings, SettingsConfigDict

# Providers we accept from env vars. Must stay in sync with:
Expand Down Expand Up @@ -52,9 +53,18 @@ 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 = Field(
default=False, validation_alias="ORCA_ALLOW_INSECURE_DEV_KEY"
)

# ── Provider keys via env (alternative to UI-stored keys) ──
# Keep in sync with `_PROVIDERS_FROM_ENV` above. Pydantic-settings reads
Expand Down
12 changes: 12 additions & 0 deletions app/main.py
Original file line number Diff line number Diff line change
Expand Up @@ -46,6 +46,18 @@ async def lifespan(app: FastAPI) -> AsyncGenerator[None, None]:
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
Expand Down
16 changes: 16 additions & 0 deletions app/routes/chat.py
Original file line number Diff line number Diff line change
Expand Up @@ -30,6 +30,7 @@
from app.deps import get_db, get_key_context
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 @@ -267,6 +268,21 @@ async def chat_completions(
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 billable (status < 400) spend. Checked before any routing,
# resolution, or cache work so an exhausted key costs the operator
# nothing — no upstream attempt, no cache fill.
if kc.budget_limit_cents is not None:
spend = await get_lifetime_spend_microcents(db, str(kc.key_id))

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 End the budget-check read transaction before the upstream call so it cannot block log commits on SQLite

For a budgeted key, the new get_lifetime_spend_microcents(db, ...) is the first statement executed on the request-scoped session db, and nothing commits or rolls it back before the handler returns. In the streaming path the request-log row is written by _finalize through a different session (session_mod._session_factory()), while the request session's transaction — and its SQLite SHARED lock, since the engine enables no WAL/busy-timeout settings — stays open across the entire upstream stream (the db dependency is torn down only after the response body completes). A separate connection's COMMIT needs an EXCLUSIVE lock and will hit SQLITE_BUSY ("database is locked", default 5s busy timeout) for as long as any budgeted streaming request is in flight, so the stream's own log write (and concurrent requests' log writes) fail, are swallowed by the except Exception: logger.warning("request_log_commit_failed") handlers, and the spend/analytics row is silently dropped — the budget then undercounts that request's spend. Before this change the streaming path never executed anything on db, so the request session never opened a transaction. Fix: end the read transaction immediately after the budget check (e.g. await db.rollback()), or run the spend lookup on a short-lived session, so the request session does not hold a read lock across the upstream call.

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.

🟠 P1 Make the budget enforcement atomic so concurrent requests cannot all pass the cap

The new budget gate is a check-then-act with no reservation: get_lifetime_spend_microcents sums only already-committed RequestLog rows, and a request's own spend row is committed only after the upstream call completes (blocking path: finally commit; streaming path: _finalize). Two or more concurrent requests for the same key therefore all read the same pre-admission sum, all pass budget_exceeded, and all get billed — the "lifetime cap" is exceeded by the sum of every in-flight request. The window is the entire upstream call (seconds), so a burst of N concurrent requests (exactly the leaked-key flood this feature exists to stop) is fully admitted and billed: the documented contract "an exhausted key costs the operator nothing" / "lifetime cap" is violated and the operator is charged beyond the limit. Fix: reserve atomically at admission instead of reading-then-checking, e.g. a per-key budget row updated with UPDATE ... SET reserved = reserved + est WHERE key_id = ? AND spent + reserved + est <= limit (and reconciled with the actual cost at completion), or hold SELECT ... FOR UPDATE on the key row from admission until the log commit so admissions per key are serialized.

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 against concurrent requests so the cap cannot be overshot

The new budget enforcement is a pure check-then-act on committed spend: get_lifetime_spend_microcents reads already-committed RequestLog rows, and budget_exceeded decides, with no lock, reservation or in-flight accounting. A key whose spend is just below budget_limit_cents can fire N parallel /v1/chat/completions requests; every one of them sees spend below the cap, all N proceed upstream, and only after they complete do their cost rows land — so total billable spend exceeds the configured cap by up to (N-1)×request cost. budget_limit_cents is described in the code and docs as a hard "lifetime cap", so the cap is not enforced under concurrency — the operator is charged beyond the configured limit. The new tests are strictly sequential and cannot exercise this. Fix by reserving estimated cost (or serializing per-key check+record in a transaction/row lock) before the upstream call, or at minimum documenting the cap as best-effort.

)

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.

🟡 P2 Make budget enforcement fail closed when request-log spend writes fail

The new budget enforcement sums spend exclusively from requests_log (get_lifetime_spend_microcents). But every request-log write in this handler swallows commit failures (except Exception as commit_err: logger.warning("request_log_commit_failed", ...) at lines 451, 558, 564, 737) and still returns 200/streams to completion. When a log commit fails (SQLite "database is locked" under concurrent writes, disk full, connection drop), the billed spend is never persisted, so every subsequent request's budget check undercounts and the 429 is never raised: an exhausted key keeps being served and billed past its cap indefinitely while the operator believes the limit is enforced. The swallow predates this commit, but the new budget control depends on those rows for its correctness and has no compensating mechanism (e.g., failing the request, or writing spend outside the best-effort log). Consequence: the accounted money limit is silently not applied on a failure path. Suggest: on log-commit failure, still surface the spend (or fail the request closed), or otherwise make the budget read a source that cannot be silently lost.

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
103 changes: 90 additions & 13 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,56 @@ 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()
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(), "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:
# Fail CLOSED: if key resolution errors (bad config, missing env, etc.)
# we must not silently report "secure" and let the app boot past the
# credential guard. Surface the error instead.
return _resolve_key_material()[1] == "dev-fallback"


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:]
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

# Legacy unversioned blob: nonce(12) || ciphertext+tag.
nonce, ciphertext = blob[:_NONCE_LEN], blob[_NONCE_LEN:]
return aes.decrypt(nonce, ciphertext, None).decode("utf-8")
34 changes: 34 additions & 0 deletions packages/auth/spend.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,34 @@
"""Per-key spend lookup used to enforce `ApiKey.budget_limit_cents`.

Semantics: `budget_limit_cents` is a lifetime cap on the key's billable
spend — request-log rows with `status_code < 400`. 1 cent = 10,000
microcents (1 USD = 1,000,000 microcents, matching chat.py's cost math).

Kept free of FastAPI imports so it stays unit-testable and reusable from
non-HTTP contexts (background jobs, CLI minting tools).
"""

from __future__ import annotations

from sqlalchemy import func, select
from sqlalchemy.ext.asyncio import AsyncSession

from packages.db.models.request_log import RequestLog

MICROCENTS_PER_CENT = 10_000


async def get_lifetime_spend_microcents(
session: AsyncSession, api_key_id: str
) -> int:
"""Sum of billable (status < 400) spend ever recorded for this key."""
stmt = select(func.coalesce(func.sum(RequestLog.cost_microcents), 0)).where(
RequestLog.api_key_id == api_key_id,
RequestLog.is_deleted == 0,
RequestLog.status_code < 400,
)

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 Count streamed-but-aborted spend toward the budget, or the cap is trivially bypassable

The new budget enforcement counts only request-log rows with status_code < 400. The streaming paths that actually consume provider-billed tokens record status 499 (client disconnect, chat.py:596) and 503 (mid-stream upstream error, chat.py:646); both are >= 400, so their cost never accrues toward get_lifetime_spend_microcents. In the disconnect case the usage chunk (stream end) usually never arrives, so the 499 row records cost 0 anyway. Either way, a client holding a budgeted key can start a streaming request, receive tokens, and disconnect — the pre-request budget check (chat.py:275-284) keeps seeing the old (never-growing) spend, so the key is never blocked while the operator's provider bill grows unbounded. The existing equivalent spend view (/v1/analytics/spend, app/routes/analytics.py:64-90) has no status filter and therefore counts these rows' cost in its total, so the dashboard's reported spend and the enforced budget disagree on the same rows. The budget's whole purpose (test docstring: "a leaked key meant unbounded spend") is defeated for streaming traffic. Fix: include 499/503 rows (they carry the tokens actually billed by the upstream) in the spend sum, or otherwise cap streaming spend before upstream dispatch.

return int((await session.execute(stmt)).scalar_one())

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 Count client-disconnect (499) spend toward the budget; a stream abort currently escapes the cap while the operator still pays upstream

get_lifetime_spend_microcents counts only rows with status_code < 400, but the streaming path records client aborts as status 499 (chat.py cancel branch sets status_code = 499 and still calls _finalize(), which computes cost from the partially-aggregated usage and persists the row). A client holding a budgeted key can stream a request, receive tokens, and drop the connection: the provider bills the tokens generated so far (real money for the operator), yet the row is recorded as 499 and is excluded from the spend sum — so the request is never counted against budget_limit_cents and can be repeated indefinitely, bypassing the cap entirely. The commit frames the cap as a lifetime limit on the operator's billable spend; a status code that is entirely client-controlled and carries real upstream cost must not silently escape the sum. Fix: include client-disconnect (499) rows in the spend aggregation, or record the disconnect cost under a billable status (e.g. keep the underlying 200/usage cost in a separate column and sum that), so an aborted stream still consumes budget.



def budget_exceeded(spend_microcents: int, budget_limit_cents: int) -> bool:
return spend_microcents >= budget_limit_cents * MICROCENTS_PER_CENT
Loading