Decrypt attr_encrypted attributes with every db_key_base key
What does this MR do and why?
Phase 1 of making the db_key_base secret rotatable: attr_encrypted attributes can now be decrypted with any configured db_key_base key.
db_key_base can be an array. The last key encrypts and every key decrypts, like Active Record Encryption keys.
Gitlab::EncryptedAttribute#attr_encrypted_decryptoverrides the gem's method and tries every key, current key first. It accepts option overrides such asiv:.- New
attr_encrypted_encrypted_with(attribute)returns:currentwhen the stored value decrypts with the current key,:previouswhen it needs an older key, ornilwhen there is nothing to decrypt. A later rake task (phase 4) will use it to skip rows that are already re-encrypted. WebHooks::Hook#decrypt_url_wasand#url_variables_wereused the current key only. They now use the same multi-key decrypt.- Gates the override behind the
attr_encrypted_multi_key_decryptfeature flag (typegitlab_com_derisk, disabled by default). When it is off, the gem's original single-key decryption is used. The actor is the current request. Rollout issue: #632071 (closed)
Nothing changes for instances today. config/settings.rb still raises when more than one key is configured. A later phase removes that raise. With a single key, behavior is unchanged: one decrypt, same errors.
Why AES-CBC attributes need extra care
GCM attributes are simple: the auth tag rejects a wrong key, so the first key that works wins.
AES-CBC attributes (for example CI variables, remote mirror credentials and pages domain keys) have no auth tag. A wrong key passes the padding check about 1 time in 256 and returns garbage without raising.
So with several keys, we try all of them:
- If only one key succeeds, we use its value.
- If several succeed, we keep only plausible values: a string with a valid encoding, or a successful load for marshaled values. Identical values (for example from keys that share their first 32 bytes) are fine. Different values raise the new
Gitlab::EncryptedAttribute::AmbiguousDecryptionError. - If no key succeeds,
OpenSSL::Cipher::CipherErroris raised.
While more than one key is configured, each CBC read decrypts once per key. That's acceptable for a temporary rotation window.
Other callers named in the issue need no change. Integration#reencrypt_properties encrypts, so the current key is right. SlackIntegration#to_database_hash and BaseDataFields#to_database_hash copy ciphertext without decrypting.
References
- Issue (phase 1): https://gitlab.com/gitlab-org/gitlab/-/issues/631455
- Epic: https://gitlab.com/groups/gitlab-org/-/work_items/23829
- Background: #25332
- Revert of the previous multi-key support: !187907 (merged)
Screenshots or screen recordings
Not applicable. This is a backend-only change.
How to set up and validate locally
-
Run the specs:
bin/rspec spec/models/concerns/gitlab/encrypted_attribute_spec.rb -
Optional: in a Rails console, check that with a single key the current key is used:
ProjectHook.last.attr_encrypted_encrypted_with(:url) # => :current
MR acceptance checklist
Evaluate this MR against the MR acceptance checklist. It helps you analyze changes to reduce risks in quality, performance, reliability, security, and maintainability.