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

  1. Install module 2.1.0 (or later), with the v1 format from issue #3590533.
  2. Create a Fuzzy-stored key and verify it decrypts.
  3. Change $settings['hash_salt'] in settings.php.
  4. 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-encrypt

Walks 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):

  1. Edit settings.php: set the new hash_salt; set fuzzy_key_provider_previous_hash_salt to the old one.
  2. Deploy.
  3. Run drush fuzzy_key_provider:re-encrypt.
  4. Remove the fuzzy_key_provider_previous_hash_salt entry from settings.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.php themselves using existing patterns.
  • No UI-driven rotation. hash_salt rotation is operator-managed; surfacing it in the admin UI conflates secrets-management with config-management.

Remaining tasks

  • Extend FuzzyConfigKeyProvider::decryptV1() to fall back to fuzzy_key_provider_previous_hash_salt on tag-verification failure.
  • Add the drush fuzzy_key_provider:re-encrypt command class plus drush/drush in require-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).

Command icon 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

mably created an issue. See original summary.

mably’s picture

Issue summary: View changes

mably’s picture

Status: Active » Needs review

  • mably committed cd211604 on 2.x
    task: #3590540 Support hash_salt rotation via previous-salts fallback...
mably’s picture

Status: Needs review » Fixed

Now that this issue is closed, review the contribution record.

As a contributor, attribute any organization that helped you, or if you volunteered your own time.

Maintainers, credit people who helped resolve this issue.

Status: Fixed » Closed (fixed)

Automatically closed - issue fixed for 2 weeks with no activity.