Summary
packages/auth/encryption.py:46-47 seals every provider credential with sha256(b"orcarouter-lite-dev-key") when CREDENTIAL_ENCRYPTION_KEY is unset:
# Dev fallback so test fixtures and `docker compose up` Just Work.
return hashlib.sha256(b"orcarouter-lite-dev-key").digest()
Nothing in the shipped configuration sets a real key — app/config.py defaults it to "", .env.example leaves it commented out (and falsely claims it is "auto-generated on first run"), and docker-compose.yml sets only DATABASE_URL. The documented docker compose up path therefore encrypts every BYOK key with a constant from public source, silently. Anyone who obtains /data/orca.db decrypts every upstream API key in one line.
Secondary issues in the same module:
- Any non-hex string is stretched into a key with a single unsalted SHA-256 (billions of guesses/sec offline).
- Ciphertext has no version byte ⇒ rotating the key permanently bricks every stored credential as an uncaught
InvalidTag.
- No warning is ever logged when the fallback is active.
Proposed fix
- Versioned ciphertext — v1 format
b"\x01" + nonce(12) + ct+tag, with transparent legacy-blob decryption (including the ~0.4% of legacy blobs whose first nonce byte collides with \x01). Round-trips stay compatible; rotation becomes possible later.
- Loud warning on first dev-fallback use (
insecure_dev_encryption_key, once per process).
- Fail-closed startup guard (
packages/db.guards.assert_credential_encryption_ready, wired into the lifespan after create_all): refuse to boot when the dev key would protect real credentials — i.e. any existing provider_keys rows, or any non-SQLite database — unless the operator sets ORCA_ALLOW_INSECURE_DEV_KEY=1 explicitly. Fresh SQLite installs keep working with the loud warning.
- Fix the stale "auto-generated on first run" comment in
config.py; document openssl rand -hex 32.
Deliberately out of scope (follow-ups): AAD binding of ciphertext to (provider, row_id), KDF hardening for passphrase-style keys (changing derivation would brick existing stored keys), automated key-rotation migration.
Acceptance criteria
Summary
packages/auth/encryption.py:46-47seals every provider credential withsha256(b"orcarouter-lite-dev-key")whenCREDENTIAL_ENCRYPTION_KEYis unset:Nothing in the shipped configuration sets a real key —
app/config.pydefaults it to"",.env.exampleleaves it commented out (and falsely claims it is "auto-generated on first run"), anddocker-compose.ymlsets onlyDATABASE_URL. The documenteddocker compose uppath therefore encrypts every BYOK key with a constant from public source, silently. Anyone who obtains/data/orca.dbdecrypts every upstream API key in one line.Secondary issues in the same module:
InvalidTag.Proposed fix
b"\x01" + nonce(12) + ct+tag, with transparent legacy-blob decryption (including the ~0.4% of legacy blobs whose first nonce byte collides with\x01). Round-trips stay compatible; rotation becomes possible later.insecure_dev_encryption_key, once per process).packages/db.guards.assert_credential_encryption_ready, wired into the lifespan aftercreate_all): refuse to boot when the dev key would protect real credentials — i.e. any existingprovider_keysrows, or any non-SQLite database — unless the operator setsORCA_ALLOW_INSECURE_DEV_KEY=1explicitly. Fresh SQLite installs keep working with the loud warning.config.py; documentopenssl rand -hex 32.Deliberately out of scope (follow-ups): AAD binding of ciphertext to
(provider, row_id), KDF hardening for passphrase-style keys (changing derivation would brick existing stored keys), automated key-rotation migration.Acceptance criteria
RuntimeError) when provider rows exist or the DB is non-SQLite and no key is configured;ORCA_ALLOW_INSECURE_DEV_KEY=1overrides.InvalidTag; truncated blobs raise rather than return garbage.