[Backend][Security] The bot API key is stored in plaintext at rest and has leaked into every backup #428
Loading…
Reference in a new issue
No description provided.
Delete branch "%!s()"
Deleting a branch is permanent. Although the deleted branch may continue to exist for a short time before it actually gets removed, it CANNOT be undone in most cases. Continue?
Severity: HIGH (security). Found while repairing dev after a production database restore.
The defect
KEY_BOT_API_KEYis listed insettings_service._ENCRYPTED_KEYS, so it is supposed to be encrypted at rest. On production it is not. Comparing the stored JSON shape of every sensitive setting:crypto.encrypt_settingwraps ciphertext as{"_enc": "<base64>"}. The bot API key is stored as{"key": "<the key>"}— the raw credential.Why it stayed that way
_ENCRYPTED_KEYSonly encrypts on write, anddecrypt_settingdeliberately passes legacy plaintext rows through unchanged ("Plaintext rows written before encryption was introduced are returned as-is"). That backwards compatibility is correct in itself, but it means a row written before encryption existed stays readable forever and nothing ever notices. The key has evidently not been rotated since, so it has never been re-encrypted.Proven, not inferred: the value decrypted correctly on the dev host, which has a different
SECRET_KEY. That is only possible if it was never encrypted.Why it matters
CLAUDE.mdsays of this credential: "Never log this value", and it is the only thing standing between the internet and every/api/bot/*endpoint.It is currently sitting in clear text in:
pg_dumpever taken, including the two in/app/backups/var/lib/docker/volumesis a backup source, and the database volume is under itAnyone with read access to a backup has the key.
Proposed fix
set_settingruns it throughencrypt_setting. Both halves must be updated together —BOT_API_KEYin.envfor the bot, and the stored setting for the backend._ENCRYPTED_KEYSrow lacking_encand rewrites it throughencrypt_setting. Backwards compatibility on read should be a migration path, not a permanent resting state._ENCRYPTED_KEYSshould log a warning at startup, so this cannot sit undetected for another release.Acceptance criteria
_ENCRYPTED_KEYSrow stored without_encis re-encrypted, with a migration or sweep covering existing installs_ENCRYPTED_KEYSvalue is written as{"_enc": ...}, and that a legacy plaintext row is upgraded rather than left aloneRelated
Discovered alongside the
SECRET_KEYrestore hazard — the two are the same investigation, and both concern what happens to secrets once they leave the running host.Code side done in
9517977onfix/v4.0.1-retention-safety, sharing one pass with #430. The rotation is still outstanding and needs you — see the bottom.What changed
A startup audit over every
_ENCRYPTED_KEYSrow. A row that is not in the{"_enc": …}form is rewritten throughencrypt_settingand named in a warning, because a credential that was readable in every backup taken until now needs rotating and the operator has to know which one.Re-encrypting is deliberately not silent. Quietly fixing it would leave the exposure — the value is already in every historical dump — while removing the only signal that it happened.
Same pass reports rows that cannot be decrypted at all (#430); they are two halves of one question, is what we stored actually protected, and can we still read it, so they share the loop.
The audit is non-fatal by design, for the reason given in #430: an operator who has just restored onto a fresh host needs the app up to fix it.
Acceptance criteria
_ENCRYPTED_KEYSrow stored without_encis re-encrypted, with a sweep covering existing installs_ENCRYPTED_KEYSvalue is written as{"_enc": ...}, and that a legacy plaintext row is upgraded rather than left alone — the first of those is the assertion whose absence let this sit undetected; there were no encryption tests at all before thisWhat still needs a human
Rotating the key is an outward-facing change I have not made. It has to happen on both sides at once —
BOT_API_KEYin.envfor the bot, and the stored setting for the backend — and bot↔backend auth breaks in between. That is a deliberate maintenance action with a visible blast radius, not something to do unannounced.The deploy makes it easy:
set_settingruns the new value throughencrypt_setting, so rotating through Admin → Bot Settings both invalidates the exposed value and stores the replacement correctly.Note the startup audit will re-encrypt the existing key on first boot after this deploys. That protects it going forward but does not un-expose it — the current value is already in every dump and every off-host archive taken to date. Only rotation fixes that.
On pruning older backups: they contain the pre-rotation key in clear text. Once rotated, the exposed value is worthless, which is the cheaper mitigation than trying to expunge it from an off-host archive history. My suggestion is rotate first, then decide about pruning separately — but it is a judgement call about your threat model, so I have left it open rather than picking for you.
Related: #429 means the eight dumps currently in
/app/backupsare unrestorable anyway and will be replaced after that fix deploys.Closed — rotated on production and verified
The key was rotated on production at 2026-08-29 06:41 UTC, and v4.0.1 (
db77a4e) is deployed.Rotation, done entirely on the host so the value never entered a terminal, a log, or a transcript — generated with
openssl rand -hex 32, held in the environment and passed over stdin, never inargv:.envwas backed up first (.env.bak-pre-botkey-20260829-064039). It holds the old key — now worthless, and the rollback if needed. Worth deleting once you're satisfied.One honest note: the first scripted attempt failed partway.
python /tmp/rotate_key.pyput/tmponsys.pathsoappwas not importable, which left.envholding the new key whileapp_settingsstill held the old one. Auth was unaffected — the bot had not reloaded.env— but any bot restart in that window would have broken it. Completed by reading the key back out of.envrather than generating a second one, so both halves are provably identical.The startup audit confirms it on the deployed release:
All four encrypted keys readable and properly encrypted at rest. Compare dev before this shipped, which reported
2 ok, 1 re-encrypted, 1 undecryptable— the re-encryption namingbot_api_key, exactly the defect this issue describes.On pruning older backups
Deciding it here, as the last criterion asked: no pruning.
Rotation makes the exposed value worthless, so its presence in historical dumps and off-host archives is no longer a live exposure. Expunging a credential from an append-only archive history is expensive, error-prone, and buys nothing once the credential is dead. Rotation is the cheaper and more complete mitigation, and it is done.
That reasoning belongs somewhere durable, so: if a credential is found in plaintext at rest, rotate it — do not try to scrub the backups. The startup audit now names any such key precisely so that decision can be made quickly.
Acceptance criteria
_ENCRYPTED_KEYSrow stored without_encis re-encrypted, with a sweep covering existing installs_ENCRYPTED_KEYSvalue is written as{"_enc": ...}, and that a legacy plaintext row is upgradedShipped in v4.0.1.