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_decrypt overrides the gem's method and tries every key, current key first. It accepts option overrides such as iv:.
  • New attr_encrypted_encrypted_with(attribute) returns :current when the stored value decrypts with the current key, :previous when it needs an older key, or nil when 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_was and #url_variables_were used the current key only. They now use the same multi-key decrypt.
  • Gates the override behind the attr_encrypted_multi_key_decrypt feature flag (type gitlab_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::CipherError is 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

Screenshots or screen recordings

Not applicable. This is a backend-only change.

How to set up and validate locally

  1. Run the specs:

    bin/rspec spec/models/concerns/gitlab/encrypted_attribute_spec.rb
  2. 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.

🤖 Generated with Claude Code

Edited by Rémy Coutable

Merge request reports

Loading
Loading