canary-key test enforces this at both the route layer and the provider adapter layer - if a regression ever leaks the key, the build fails.
Encryption at rest
On the hosted deployment, each key is protected by two layers:- A per-bot DEK (Data Encryption Key, AES-256-GCM) encrypts the key itself.
- A KEK (Key Encryption Key) loaded from the
PROBOT_KEY_ENCRYPTION_KEYenv var wraps the DEK. The KEK never touches the database.
encrypted_llm_keys table (one row per bot). A database-only leak is useless without the KEK. Rotating the KEK re-wraps every DEK without touching the ciphertext keys themselves - see KEY-STORAGE.md.
How a chat request resolves the key
The chat route accepts an optionalx-llm-api-key header (used by the owner’s own dashboard test chat) and falls back to the managed encrypted key otherwise:
- Header present (owner testing from their own browser) - used directly.
- Managed encrypted key - unwrapped with the KEK, decrypted in memory, used once, discarded.
- Neither - request fails with
missing_llm_key.
Where the key is
The JSON body is { message, sessionId } - the key is never a body field on any surface.
Why this shape
A traditional “AI chatbot service” stores your API key server-side in plaintext, calls the provider on your behalf, and gives you no visibility. That leaves a persistent, subpoenable secret sitting in a shared database. ProBot’s design inverts that:- Ciphertext at rest, plaintext for microseconds. A DB dump alone reveals nothing. Only a running server with the KEK can decrypt.
- No key column in
botsorusers. Theencrypted_llm_keystable is the only place a key ever appears, and only as ciphertext. - You pay the provider directly. Envelope encryption doesn’t change your billing relationship - ProBot never proxies charges.
Provider adapter - per-request client
Every adapter (Anthropic, OpenAI, Azure, Google, Grok) constructs a new client per request with the decrypted key. There is nolet cachedClient, no Map<userId, Client>, no global. The canary-key test plants a known canary string as the plaintext key and asserts it appears only in the outbound HTTPS payload - never in a response, never in a log mock, never in an error.
Implementation footnotes
- Azure keys are header-only. Azure’s multi-secret credential (key + endpoint + apiVersion) isn’t supported by managed storage in this release, so Azure bots require the owner to be online with the credentials in their browser store, or a self-hosted bot via the
probot-self-hostednpm package. Other providers all go through managed encryption. - Self-hosting the bot. If you’d rather not trust any operator, install the
probot-self-hostednpm package in your own web app - the plaintext key lives entirely inside your backend and never touches pro-bot.dev. - Owner test chat. When the owner tests their bot from the dashboard, the browser passes the key as
x-llm-api-key. The dashboard mirrors the key into a browser-side encrypted store (IndexedDB + non-extractable Web Crypto key) so the owner doesn’t re-paste it on every reload.
Failure modes (and how the key is protected in each)
Threat model
BYO-key with envelope encryption protects you against:- Server-side breach of ProBot’s database. Ciphertext + wrapped DEK are useless without the KEK.
- Cross-tenant leakage. Every request constructs a fresh provider client with the just-decrypted key.
- Vendor lock-in / surprise billing. You hold the contract with Anthropic / OpenAI / Azure directly.
- Full infrastructure compromise. Anyone with deploy access can read the KEK. If that’s your threat model, self-host the bot with the
probot-self-hostednpm package - your LLM key never touches pro-bot.dev. - XSS on the ProBot frontend. The frontend mitigates this with
react-markdown+ norehype-raw; if you find a gap, please report via SECURITY.md. - Phishing. A lookalike domain could capture pasted keys.