Rotate the credential encryption key without losing a row - #380
Open
haksungjang wants to merge 4 commits into
Open
Rotate the credential encryption key without losing a row#380haksungjang wants to merge 4 commits into
haksungjang wants to merge 4 commits into
Conversation
…safe The rotation statements interpolate table and column names, because a bind parameter cannot stand where an identifier goes. Semgrep flags that, and the answer was a comment saying the values are literals. EncryptedColumn now refuses anything that is not a plain identifier when it is constructed, so the property is checked rather than argued, and the semgrep suppressions point at that check.
The command had no test that ran it, which rule 6 is about: covering the service it calls says nothing about whether the command reaches it, or what it exits with. The exit code is what the runbook tells an operator to script. Also covers parsing the key list (order decides which key encrypts, so a parser that read the wrong end would never converge) and both branches of the boot census, including the guard that skips it when only one key is set.
The precondition fixture nulled every column in the registry, and github_app_credentials.private_key_encrypted is NOT NULL. It passed locally because nothing had created such a row yet; CI runs the GitHub App suite first and hit the constraint five times. Credential tables are deleted, projects columns are nulled, since a project row holds much more than ciphertext.
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
GITHUB_APP_ENCRYPTION_KEYnow takes a comma-separated list. The first key encrypts, every key decrypts, so a rotation is: put the new key first, re-encrypt, remove the old one. A deployment with one key has a value containing no comma and reaches the same path it did before, which is why the variable was widened rather than replaced.The dangerous part is not the re-encryption, it is knowing when it has finished. Removing a key while rows still hold ciphertext written under it makes those rows unreadable permanently, and nothing about the running system looks wrong until something reads one.
So counting what is left and rewriting what is left go through one predicate,
is_on_current_key. Not two implementations kept in agreement: one implementation. A Fernet token names no key, so "which key wrote this row" can only be answered by trying to open it; at ten thousand rows that is under a tenth of a second, so the scan is a full one.MODE=countreports and changes nothing;MODE=rewritemoves rows. Both repeat safely, andrewriteinterrupts safely because rows already on the newest key are skipped. The update is conditional on the value that was read, so a row somebody changed mid-pass updates nothing and keeps what they wrote: an application write already used the newest key.The count also appears in the boot log, and only when more than one key is configured. The person who runs the re-encryption and the person who edits the environment are often not the same person, and the command's output was only ever on the first one's terminal.
scripts/restore.shruns the same count after a restore. A backup carries ciphertext written under whatever key was current when it was taken, and the runbook states the retention rule that follows: the old key must outlive the backups that predate the rotation.Counting had to distinguish two states
countoriginally answered "is this row on an older key". That merges two situations needing opposite actions: a row an older key still opens is work the rewrite does, and a row no configured key opens is a key already removed too early, where running the rewrite cannot help. Reported as one number, an operator runs a command that cannot fix what they have and watches the number not move.A column that changes keys without a migration
core/encrypted_columns.pylists every column holding ciphertext and the key that opens it, and a contract test fails when the code and the list disagree, or when a column changes keys.This is not hypothetical. A change in flight moved
registry_credentials.password_encryptedfrom the shared key to a derived subkey with no re-encryption, which would have made every already-stored registry password unreadable. The module it edited carried a docstring forbidding that outcome by a different route, and a docstring cannot fail. Planting the same move here fails with a message naming both keys and saying a re-encryption migration has to come with it.Verification
The rotation sequence is exercised against a real database, ending with the old key removed and the value still readable, because a test that stopped earlier would not have shown the earlier steps were enough.
Three mutations, each confirmed applied. Removing the conditional from the update fails the race test; making the predicate always report the current key fails four. An earlier version of the race test built the UPDATE itself and survived the first mutation, since the statement it checked was the one it had written; it now drives the service's own pass over a stale value. The implementation later changed from
rowcounttoRETURNINGand the mutations were re-run, because a mutation result is about the implementation it ran against.Full backend suite green, mypy clean across 927 files, ruff clean, ko-style 0, docs-uat 0 errors.