Problem/Motivation
The Fuzzy Config Key Provider derives its encryption key deterministically from $settings['hash_salt']. Any change to hash_salt — a routine compliance action — silently turns every stored key into unrecoverable bytes. There is no warning, no fallback, no way to read old values to re-encrypt them after the rotation.
This blocks operators who would otherwise rotate hash_salt on schedule (annual key rotation, post-incident rotation, key-management policy alignment). The provider's encryption-at-rest value proposition is undermined by the impossibility of safe rotation.
Steps to reproduce
- Install module 2.1.0 (or later), with the v1 format from issue #3590533.
- Create a Fuzzy-stored key and verify it decrypts.
- Change
$settings['hash_salt']insettings.php. - Read the key value — empty string returned, original plaintext gone. No log entry indicates why.
Proposed resolution
Depends on issue #3590533 — the v1 (AES-256-GCM) format must be in place. The GCM authentication tag is what makes the design below safe: trial-decrypting with a wrong key fails loudly (tag mismatch → openssl_decrypt returns FALSE), which is unsafe with AES-CBC where the wrong key silently returns garbage that may look like valid plaintext.
1. Add an opt-in previous-salt fallback. A new settings.php entry holds the prior hash_salt value to try during decryption:
// settings.php $settings['hash_salt'] = 'new-salt'; $settings['fuzzy_key_provider_previous_hash_salt'] = 'old-salt';
Single scalar, not a list. Drupal's recommended rotation procedure (below) keeps the previous-salt slot occupied for exactly one rotation cycle, so a list of previous salts would be speculative complexity. If a site needs to chain rotations, they script A → re-encrypt → B → re-encrypt → C explicitly; each step uses one previous salt. The scalar also enforces "migrate fully before rotating again" discipline — valuable for an audit-trail-grade provider.
decryptV1() attempts the current hash_salt first. On GCM tag-verification failure (openssl_decrypt returns FALSE) it falls back to fuzzy_key_provider_previous_hash_salt if set, and returns the plaintext if that tag verifies. Encryption always uses the current salt — new writes are immediately tied to the post-rotation key.
Reads pay at most one extra GCM operation on a miss. After running the re-encrypt command (below), the steady state is one successful tag verification per read.
2. Add the Drush command deferred from #3590533.
drush fuzzy_key_provider:re-encryptWalks every key.key.* config entity whose provider is fuzzy_config, reads the value (succeeding via the current salt or the previous-salt fallback), and re-encrypts it with the current salt. Reuses the static KeyMigrator::reEncryptAll() helper already introduced in #3590533. Re-runnable — every read decrypts cleanly, so the command is safe to invoke any number of times.
Note on idempotency. The walker in #3590533 detects "already v1" by prefix match. To detect "already encrypted under the current salt" we cannot inspect the ciphertext directly. The walker therefore re-saves every v1 key on each run — a couple of openssl operations per key, no functional change to the stored semantic value, just a fresh random IV. Operators running the command twice in a row see no observable difference.
3. End-to-end rotation procedure (documented in README):
- Edit
settings.php: set the newhash_salt; setfuzzy_key_provider_previous_hash_saltto the old one. - Deploy.
- Run
drush fuzzy_key_provider:re-encrypt. - Remove the
fuzzy_key_provider_previous_hash_saltentry fromsettings.php.
Out of scope
- Legacy (pre-2.1) values do not get rotation support. The legacy CBC path returns garbage on a wrong key with no way to detect the failure, so trying a previous salt there would silently corrupt readable plaintext into arbitrary bytes. Sites that have not yet run the post-update from #3590533 must do so first, then rotate.
- Chained rotations without intervening migration. If a site rotates A → B, skips the re-encrypt, then rotates B → C, the keys encrypted under A become unrecoverable — by design. Documented as a hard prerequisite of the procedure.
- No automatic salt-discovery from environment-managed secrets backends (Vault, AWS Secrets Manager). Operators wire those into
settings.phpthemselves using existing patterns. - No UI-driven rotation.
hash_saltrotation is operator-managed; surfacing it in the admin UI conflates secrets-management with config-management.
Remaining tasks
- Extend
FuzzyConfigKeyProvider::decryptV1()to fall back tofuzzy_key_provider_previous_hash_salton tag-verification failure. - Add the
drush fuzzy_key_provider:re-encryptcommand class plusdrush/drushinrequire-dev. - Add kernel test coverage: fallback succeeds on a previous-salt v1 ciphertext; fallback returns empty when no salt matches; re-encrypt walker re-keys ciphertexts to the current salt.
- Update README with the operational procedure under the existing Caveats section (which currently links forward to this issue).
API changes
None at the public-method level. One new settings.php entry (fuzzy_key_provider_previous_hash_salt) — opt-in, scalar string, no default. One new Drush command.
Data model changes
None. Stored format remains v1.
Related
Depends on #3590533 (v1 format + KeyMigrator helper).
Issue fork fuzzy_key_provider-3590540
Show commands
Start within a Git clone of the project using the version control instructions.
Or, if you do not have SSH keys set up on git.drupalcode.org:
Comments
Comment #2
mably commentedComment #4
mably commentedComment #6
mably commented