[Backend][DR] Restoring a database onto a host with a different SECRET_KEY destroys every encrypted setting, and raises an unhandled InvalidTag #430
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 (disaster recovery). Hit for real while restoring production data onto dev to rehearse the v4.0.0 migrations.
The defect
Sensitive settings are encrypted at rest with a key derived from
SECRET_KEY(crypto._derive_key).SECRET_KEYlives in.env, which is not in the database and therefore not in any database backup.So restoring a database onto a host whose
SECRET_KEYdiffers — a rebuilt host, a new machine, a DR event, or a dev host taking a copy of production — leaves every_ENCRYPTED_KEYSrow permanently unreadable. Whisper endpoint, LLM endpoint and key, Discord bot token, SMTP password: all present, all garbage.Observed exactly that:
Two things make it worse than it needs to be
1. It raises where every caller expects
None.get_llm_config's own docstring commits to returningNonewhen the config is unusable, andsettings_service's module docstring lays out four policies callers use for exactly that case. A decryption failure bypasses all of it:decrypt_settingraisesInvalidTagout ofget_setting, and nothing catches it. Every LLM-touching endpoint and task fails with an opaque cryptography exception instead of the well-defined "not configured" path that already exists.Worth noting the ordering hazard too: an undecryptable row is worse than an absent one, because absent degrades gracefully and undecryptable does not.
2. Nothing warns you, before or after.
docs/OPERATIONS.mdmentionsSECRET_KEYexactly once, in an env-var table. Its restore and backup sections say nothing about encrypted settings being tied to it. Following the documented restore procedure onto a fresh host produces a system that looks restored and is not — and the failure surfaces later, asInvalidTag, somewhere unrelated to the restore.Proposed fix
get_setting, log once and clearly ("settingllm_configcould not be decrypted — it was encrypted with a different SECRET_KEY; re-enter it in Admin"), and returnNoneso the existing not-configured policies take over._ENCRYPTED_KEYSrow decrypts, reported loudly, so an operator learns during the restore rather than when a session fails to summarise.SECRET_KEYis part of the backup set. A database dump alone is not a recoverable system, and the restore procedure should say so and list what must be re-entered if the key is lost.Acceptance criteria
Nonefromget_settingwith a clear one-time log line, never an unhandledInvalidTag_ENCRYPTED_KEYSrow that cannot be decrypteddocs/OPERATIONS.mdstates thatSECRET_KEYmust be backed up alongside the database, and documents recovery when it is lostRelated
Fixed in
9517977onfix/v4.0.1-retention-safety, sharing one pass with #428.What changed
decrypt_settingnow raisesUndecryptableSettingErrorinstead of lettingInvalidTagescape, andget_settingcatches it and returnsNone— so the four "not configured" policies in the module docstring actually apply, which is what the issue asked for.The catch in
cryptois deliberately broad. A wrong key raisesInvalidTag, but a truncated or hand-edited row raises from base64, from the slicing, or from json — and every one of them means the same thing to a caller: this value is not readable. Catching onlyInvalidTagwould have left three other routes to the same opaque failure.Worth stating plainly, and it is in the docstring: this is a demotion, not a repair. An undecryptable row is worse than an absent one, because the operator believes the setting is configured. Returning
Noneonly stops it taking the process down somewhere unrelated to the restore — hence the log line, the startup report, and the Admin surface.Acceptance criteria
Nonewith a clear one-time log line, never an unhandledInvalidTag— deduped per key, because settings are read on nearly every request and an un-deduped line would bury the startup report it points at_ENCRYPTED_KEYSrow that cannot be decrypted — in the lifespan, and deliberately non-fatal: an operator who has just restored onto a fresh host needs the app up to re-enter the settings the report is telling them about, and a stricter check would lock them out of the only UI that can fix itGET /api/admin/settings/secrets-health, read-only (reencrypt=False) so hitting it cannot mutate datadocs/OPERATIONS.mdstates thatSECRET_KEYmust be backed up alongside the database, and documents recovery when it is lost — a callout at the head of the restore procedure, plus the env-var table, plus what the startup line looks like when this has happenedOn the tests
There were no tests for encryption at all before this, which is most of the explanation for both this and #428: nothing asserted that an
_ENCRYPTED_KEYSrow is actually stored encrypted, and nothing ever read a row back under a different key.The mismatch is driven by swapping
SECRET_KEYin the environment between the write and the read — faithful, since_derive_keyresolves it per call, so that genuinely is "this database came from another host".Verified by reverting the catch:
test_get_setting_returns_none_for_an_undecryptable_rowandtest_get_llm_config_degrades_rather_than_raisingboth fail there with the exception escaping, exactly as production did.1,407 backend tests pass.