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_decrypt returns FALSE) instead of returning silently corrupted plaintext.
  • Reintroduces the drush fuzzy_key_provider:re-encrypt command (deferred from #3590533). Walks every fuzzy_config key 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:

  1. Edit settings.php: set the new hash_salt, set the old value as $settings['fuzzy_key_provider_previous_hash_salt'] (singular).
  2. Deploy.
  3. Run drush fuzzy_key_provider:re-encrypt.
  4. Remove the fuzzy_key_provider_previous_hash_salt entry from settings.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 with hash_salt or 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, runs KeyMigrator::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.

Merge request reports

Loading