Skip to content

fix(auth): fail closed on the public dev encryption key, add versioned ciphertext - #77

Closed
hasitpbhatt wants to merge 5 commits into
Continuum-AI-Corp:mainfrom
hasitpbhatt:fix/encryption-fail-closed
Closed

fix(auth): fail closed on the public dev encryption key, add versioned ciphertext#77
hasitpbhatt wants to merge 5 commits into
Continuum-AI-Corp:mainfrom
hasitpbhatt:fix/encryption-fail-closed

Conversation

@hasitpbhatt

@hasitpbhatt hasitpbhatt commented Aug 21, 2026

Copy link
Copy Markdown
Contributor

Orca-Code-Review — push 2

Severity Count Δ vs previous push
P0 0 0
P1 2 +1
P2 3 -2
P3 0 -2

❌ 2 findings block merge

Problem

The default deployment sealed every BYOK provider key with sha256(b"orcarouter-lite-dev-key") — a constant in public source — with no warning and no way to rotate. .env.example even claimed the real key was "auto-generated on first run", which it never was.

Full analysis in #76.

Fix

  • packages/auth/encryption.py: ciphertexts now version-prefixed (b"\x01" + nonce + ct+tag) enabling future rotation; legacy unversioned blobs keep decrypting, including the ~0.4% whose first nonce byte collides with the version byte (v1 parse attempted first, legacy on InvalidTag). First use of the dev fallback logs a prominent insecure_dev_encryption_key warning.
  • packages/db/guards.py (new): assert_credential_encryption_ready() — fail-closed boot when the dev key would guard existing provider rows or any non-SQLite database; fresh SQLite installs pass; explicit ORCA_ALLOW_INSECURE_DEV_KEY=1 (env) or allow_insecure_dev_key (Settings) opts out.
  • app/main.py: guard wired into the lifespan after create_all.
  • app/config.py: new allow_insecure_dev_key: bool = False; stale "auto-generated on first run" comment corrected to point at openssl rand -hex 32.

Tests

  • tests/unit/test_encryption_format.py (5): versioned blob shape + round-trip; legacy blob decrypt; nonce-collision recovery; wrong-key InvalidTag; truncated blob raises.
  • tests/unit/test_startup_guards.py (6): fresh sqlite allowed; rows ⇒ RuntimeError; non-sqlite ⇒ RuntimeError even empty; both opt-in paths bypass; no-op when a real key is configured.

Verification

  • Full suite: 333 passed
  • ruff check app packages tests: clean

Stacked on #75 (shares lifespan/test infra).

Closes #76

@orcacode-review orcacode-review Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

🐳 OrcaCode Review

Found 8 issues in this PR: 🟠 1 P1 · 🟡 5 P2 · ⚪ 2 P3.

Not attached to a line. GitHub only accepts an inline comment on a line this PR changes. The findings below point somewhere else, so they are listed here instead of being dropped.


.env.example (line 19): ⚪ P3 Update .env.example Encryption section for the new fail-closed behavior

The same commit that made the dev-key behavior fail-closed updated the comment in app/config.py ("credential_encryption_key is NOT auto-generated ... guards refuses to boot") and added the allow_insecure_dev_key opt-out, but the repository's config template was not touched. .env.example still says "# ── Encryption (auto-generated on first run if empty) ──" and gives no hint about the new opt-out flag. An operator who copies the example and leaves CREDENTIAL_ENCRYPTION_KEY empty (as "auto-generated" implies is fine) now hits the new startup RuntimeError on any non-SQLite database or as soon as a provider key exists, with the only escape being an undocumented env name (see the other finding about ORCA_ALLOW_INSECURE_DEV_KEY). Update .env.example's Encryption section to match the new fail-closed behavior and list the opt-out flag.


app/routes/keys.py (line 76): ⚪ P3 Expose model_allowlist/budget_limit_cents in GET /v1/keys serialization

create_key now returns model_allowlist and budget_limit_cents in its response, and the whole point of the feature is provisioning restricted keys. But GET /v1/keys (list_keys) was not updated to serialize these fields, so the only read path for existing keys cannot show which keys carry restrictions. An operator auditing keys or building tooling on the list endpoint cannot see budgets/allowlists, while the create response exposes them — the API shape is split. Add "model_allowlist" and "budget_limit_cents" to the list_keys serialization (and the dashboard render, which currently has no way to display or edit restrictions).

Reviewed via OrcaRouter — Route Smarter. Ship Safer. Spend Less.

Comment thread packages/db/guards.py
def _allow_flag_enabled(settings_value: bool, os_environ) -> bool:
if settings_value:
return True
return str(os_environ.get(_ALLOW_FLAG_ENV, "")).lower() in ("1", "true", "yes")

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 the insecure-dev-key opt-out honor the Settings field / repo env naming

The new opt-out exists under two names that never meet. The Settings field added in app/config.py is allow_insecure_dev_key, which pydantic-settings (no env_prefix configured anywhere in this repo; every other setting uses its bare name, e.g. DATABASE_URL, CREDENTIAL_ENCRYPTION_KEY) reads from ALLOW_INSECURE_DEV_KEY / .env. But the guard reads ORCA_ALLOW_INSECURE_DEV_KEY straight from os.environ (_ALLOW_FLAG_ENV), and every user-facing message (guards.py:76 RuntimeError remediation, encryption.py:16,57 warning) instructs operators to "set ORCA_ALLOW_INSECURE_DEV_KEY=1". ORCA_ALLOW_INSECURE_DEV_KEY is not a field alias, so in .env (this repo's documented config file; pydantic-settings loads .env itself and nothing calls load_dotenv, and os.environ is only populated from .env when run under docker-compose's env_file) the flag is invisible to both the Settings field and the guard's os.environ read. A user running uvicorn app.main:app (scripts/start.py path) with a Postgres DB or existing provider rows who follows the printed remediation by adding ORCA_ALLOW_INSECURE_DEV_KEY=1 to .env still gets the RuntimeError and the app refuses to boot. Fix: drop the separate env name and have the guard consult the Settings value (already passed as allow_insecure_dev_key) as the single source of truth, and document that name in .env.example; or add an env_prefix/alias so the documented name maps to the field.

Comment thread packages/db/guards.py

return int(
(await session.execute(select(func.count()).select_from(ProviderKey))).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 only live (is_deleted == 0) provider keys in the startup guard

_count_provider_keys counts every row in provider_keys with no is_deleted == 0 filter, unlike every other ProviderKey query in the codebase (providers.py list/PUT, hosted.py, analytics.py, and build_deployments/usable_providers_from_db all treat soft-deleted rows as absent). The repo's own providers.py documents that pre-existing dev DBs carry soft-delete tombstones from before the hard-delete change (b6fed33). Upgrade path: an operator's SQLite DB has only tombstoned provider-key rows (keys they deleted via the old soft-delete flow), no CREDENTIAL_ENCRYPTION_KEY, and the new guard runs at boot. The count returns N>0, so is_sqlite and key_rows == 0 is false and boot fails with RuntimeError "N provider key(s) already exist in this database ... re-save your provider keys" — even though no live credential is at risk and there is nothing to re-save. The old version booted and served fine in this state. The guard's own contract ("refuse to boot once real credentials are at stake", "zero stored provider keys -> allowed (fresh dev install)") is contradicted by counting deleted rows; the fix is to add ProviderKey.is_deleted == 0 to the count, matching every sibling query.

Comment thread app/routes/chat.py
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.

Comment thread app/routes/chat.py
# 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.

Comment thread packages/db/guards.py
key_rows = 0

if is_sqlite and key_rows == 0:
return

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 Guard fails open when the provider-key count cannot be completed

assert_credential_encryption_ready is a fail-closed control: it must refuse to boot whenever the publicly-known dev encryption key would protect real provider credentials. The except Exception: key_rows = 0 branch converts ANY failure of the count query (SQLite lock, I/O error, connection failure, corrupted table) into "zero provider keys", and for SQLite (is_sqlite and key_rows == 0) the guard then returns normally — the app boots and keeps provider keys sealed with the public dev key, exactly the condition the guard exists to refuse. In main.py the guard runs after Base.metadata.create_all, so a missing table cannot occur there; every other exception is a genuine DB failure and should fail closed (re-raise) rather than be treated as "empty database". Only an explicit "no such table" error should map to zero rows. As written, a transient error (e.g. database is locked from a second instance sharing the SQLite file) silently defeats the security control and boot proceeds with insecure sealing.

Comment thread packages/auth/spend.py
RequestLog.is_deleted == 0,
RequestLog.status_code < 400,
)
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.

@orcacode-review orcacode-review Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

🐳 OrcaCode Review

Found 5 issues in this PR: 🟠 2 P1 · 🟡 3 P2.

Not attached to a line. GitHub only accepts an inline comment on a line this PR changes. The findings below point somewhere else, so they are listed here instead of being dropped.


app/routes/keys.py (line 76): 🟡 P2 Return model_allowlist and budget_limit_cents from GET /v1/keys to match the create response

This change adds model_allowlist and budget_limit_cents to the POST /v1/keys create response (keys.py:106-107) and to the ApiKey model, and require_unrestricted/budget enforcement make those fields meaningful restrictions. But the read side of the same resource — GET /v1/keys — was left untouched and still serializes only id/name/key_prefix/is_active/last_used_at/revoked_at/created_at. A client that creates a restricted key and then lists keys (the only audit surface for the new restriction feature) cannot see which keys carry an allowlist or budget, so it cannot display or manage them; the restriction data is invisible once the create-time plaintext response is gone. The serialization on the wire was updated for one endpoint of the resource and not the other.

Reviewed via OrcaRouter — Route Smarter. Ship Safer. Spend Less.

Comment thread app/routes/chat.py
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 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.

Comment thread packages/auth/spend.py
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.

Comment thread app/routes/chat.py
),
)

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.

Comment thread packages/db/guards.py
select(func.count())
.select_from(ProviderKey)
.where(ProviderKey.is_deleted == 0)
)

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 soft-deleted provider rows in the credential-encryption guard

_count_provider_keys counts only rows with is_deleted == 0, so a SQLite DB whose provider_keys rows are all soft-deleted tombstones (pre-hard-delete dev DBs, which the code elsewhere explicitly acknowledges exist: providers.py "pre-PR dev DBs might carry tombstones") reports key_rows=0 and the guard lets the app boot with the publicly-known dev encryption key. Those tombstoned rows still contain live provider credentials sealed with the dev key, so the guard's stated contract — "refuse to boot when provider credentials would be sealed with the publicly-known dev key" — fails open for exactly the credential-at-rest case it exists to prevent. The previous commit's version of this guard counted all rows (fail-closed); the live-row filter is a relaxation introduced in this commit. Low confidence because the tombstone scenario is legacy-DB-only and the filter is a documented, deliberate choice, but the guard's security promise is nonetheless not met for tombstoned secrets.

@hasitpbhatt

Copy link
Copy Markdown
Contributor Author

Addressing the OrcaCode review findings on this PR:

  • .env.example (P3) — Encryption section rewritten to state the key is required (openssl rand -hex 32), the fail-closed behavior, and the ORCA_ALLOW_INSECURE_DEV_KEY opt-out. Done in e547d71.
  • list_keys exposes model_allowlist/�udget_limit_cents (P3) — added to the list serialization in PR fix(keys): block restricted API keys from minting or managing other keys #71 ( ix/keys-privilege-escalation).
  • Opt-out honors ORCA_ALLOW_INSECURE_DEV_KEY (P2) — Settings.allow_insecure_dev_key now has �alidation_alias="ORCA_ALLOW_INSECURE_DEV_KEY", so the flag works from .env. Done in e547d71.
  • Guard counts only live rows (is_deleted == 0) (P2) — _count_provider_keys now filters soft-deleted tombstones. Done in e547d71.
  • Guard fails closed on count error (P2) — only a genuinely missing table maps to zero rows; any other DB error re-raises. Done in e547d71.
  • Budget check atomic (P1) + count aborted/errored streams (P1/P2) + end read txn before upstream (P2) — implemented on the budget branch ( ix/budget-enforcement, PR feat(budget): enforce ApiKey.budget_limit_cents on the chat path #73): per-key �syncio.Lock held through the log commit, spend now sums all non-deleted rows (incl. 499/503), and �wait db.rollback() closes the read transaction so the request session doesn't hold a SQLite SHARED lock across the upstream call. Latest commit 9b8143.

All changes are stacked; the tip of ix/budget-enforcement contains every fix above.

@hasitpbhatt

Copy link
Copy Markdown
Contributor Author

Closing as part of PR-hygiene cleanup. This was part of an interdependent stack (#71 -> #75 -> #77 -> #81 -> #83 -> #85) rather than an independent branch from latest main. The unique work will be re-raised as clean, independent PRs branched directly from main, brought to distinguished engineering quality, and passed through a rigorous review gate before re-opening. Keys scoping + restricted-key blocking is already superseded by #89; encryption fail-closed / log-redaction / unhandled-exception work has largely landed via #87/#88; remaining unique parts (cache v2 key space, latency logging, async exception handler) will be re-raised independently. Reopen if you want to keep this branch.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

fix(auth): provider keys sealed with a publicly-known dev key by default — fail closed at startup

1 participant