Issue #3590540: Support hash_salt rotation via previous-salt fallback
Summary
Builds on the v1 GCM format from #3590533 to support graceful rotation of $settings['hash_salt'].
decryptV1()tries the current salt first; on GCM tag-verification failure falls back to$settings['fuzzy_key_provider_previous_hash_salt']if set. The auth tag is what makes the fallback safe: a wrong salt fails loudly (openssl_decryptreturns FALSE) instead of returning silently corrupted plaintext.- Reintroduces the
drush fuzzy_key_provider:re-encryptcommand (deferred from #3590533). Walks everyfuzzy_configkey and re-encrypts under the current salt. Idempotent. KeyMigrator::reEncryptAll()drops the v1-prefix skip because v1 values may now be under a previous salt. The walker is salt-agnostic and re-encrypts every non-empty stored value to v1 under the current salt. The post-update from #3590533 calls the same walker — slightly more work on the upgrade pass (already-v1 values get re-saved), but the operation is semantically idempotent and the IV churn is small.
Rotation procedure
Documented in README under the new Rotating hash_salt section:
- Edit
settings.php: set the newhash_salt, set the old value as$settings['fuzzy_key_provider_previous_hash_salt'](singular). - Deploy.
- Run
drush fuzzy_key_provider:re-encrypt. - Remove the
fuzzy_key_provider_previous_hash_saltentry fromsettings.php.
New writes always use the current salt, so keys created or edited via the admin UI after step 1 are immediately under the new salt regardless of whether step 3 has run yet.
README changes
- New ## Threat model section explicitly scoping what encryption-at-rest does and does not protect against (attacker with config-storage access but not
hash_salt: blocked; attacker withhash_saltor code execution: out of scope). - New ## Rotating hash_salt section with the 4-step procedure above.
Tests
Locally green (7 tests, 22 assertions, OK):
- Existing v1 round-trip, IV randomness, GCM tampering, legacy fallback (all preserved from #3590533).
- Existing migrator test updated to reflect the salt-agnostic walker (re-encrypts both legacy and existing v1 values; "skipped" is now only for empty stored values).
- New
testPreviousSaltFallback: writes a key under salt A, swaps to salt B with A listed as the previous salt, asserts the value still decrypts. Then removes the previous-salt setting and confirms the value no longer decrypts (proves the fallback is what's doing the work, not a stale cache). - New
testMigratorRotatesSalt: end-to-end rotation flow — writes under salt A, rotates to B with A as previous, runsKeyMigrator::reEncryptAll(), removes previous-salt, confirms the value decrypts under the current salt alone.
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. drush/drush added to composer.json require-dev so PHPStan resolves the Drush attribute classes in CI.
Versioning
Targeted at 2.1.0 alongside #3590533. Public API unchanged; the only new external surface is one opt-in settings entry and one Drush command. Minor bump remains appropriate.