Skip to content

Encryption key rotation, AES-256 keys, drop illuminate/encryption - #72

Closed
TomK wants to merge 4 commits into
2.0from
encryption-key-rotation
Closed

TomK wants to merge 4 commits into
2.0from
encryption-key-rotation

Conversation

@TomK

@TomK TomK commented Sep 29, 2026 •

Copy link
Copy Markdown
Contributor

encrypter accepts only one AES-128 key, so replacing [security] encryption_key makes everything encrypted under the old key (e.g. cookies) undecryptable at once.

EncryptionService now binds Cubex\Encryption\Encrypter, a standalone implementation of Illuminate\Contracts\Encryption\Encrypter. It reads and writes exactly the illuminate/encryption 5.x payload: base64(json({iv, value, mac})), openssl AES-CBC, HMAC-SHA256 MAC checked with the same double-HMAC comparison. It throws the contracts' DecryptException/EncryptException, so Illuminate\Cookie\Middleware\EncryptCookies works unchanged and existing cookies keep decrypting. illuminate/encryption moves to require-dev (tests only), and illuminate/contracts becomes a direct requirement (it was already pulled in by container/support/cookie). The bound object is no longer an instance of Illuminate\Encryption\Encrypter; code that type-hints the contract is unaffected.

encryption_key can be a key or a list. The first key is current: it encrypts, is returned by getKey(), and is tried first on decrypt. Later keys are only used to decrypt, in order, and the first failure is rethrown if none match. Each key's cipher follows its length: 16 bytes is AES-128-CBC, 32 bytes is AES-256-CBC, anything else throws at boot. There is no cipher setting.

[security]
encryption_key[] = {{ENV:COOKIE_ENCRYPTION_KEY}}   ; 32 chars, AES-256-CBC
encryption_key[] = <old 16-char key>               ; AES-128-CBC, decrypt only

A single 16-byte string key behaves as before. Empty later entries are ignored. An empty first entry throws instead of promoting an old key, so an unset env var can't cause new data to be encrypted with a retired key. A missing item or [] uses the existing default key.

Fallback keys are usually being retired because they're compromised, so anything that decrypts with one may be forged. Keep the window short and don't base security decisions on cookie contents during it; consumers can re-issue cookies to move them onto the current key. decrypt() still calls plain unserialize() like Illuminate's; untrusted input should use $unserialize = false with allowed_classes => false.

Test plan

EncrypterTest checks compatibility against the real illuminate/encryption 5.5.44 in both directions for AES-128-CBC and AES-256-CBC, covering serialized values, $unserialize = false and the *String methods. It also covers:

  • the payload format
  • a tampered MAC or value throwing, and malformed payloads throwing
  • implementing the contract
  • string and list keys, first key encrypts, fallback decrypts, and a 32-byte current key with a 16-byte fallback decrypting an old AES-128 payload
  • no match throwing
  • empty-entry rules, and bad key lengths throwing

EncryptionServiceTest covers string, list, 32-byte, missing and [] config.

Results on PHP 7.4 with this branch's own dependencies (PHPUnit 6.5):

  • Full suite: 323 tests. The 40 encryption and service manager tests pass. The only 2 errors are the ReflectionType::__toString() deprecations in CubexTest, which fail the same way on 2.6.8.
  • Install without dev dependencies: a --no-dev install leaves out illuminate/encryption, and the encrypter still works there.

Rollout

  1. Release 2.6.9 from 2.0 with gh release create.
  2. megameld bumps cubex/framework and sets encryption_key[] to the new 32-char env key first and the old 16-char key second.
  3. After a short window, remove the old key entry.

🤖 Generated with Claude Code

TomK and others added 3 commits September 29, 2026 09:52
[security] encryption_key now also accepts a list of keys. The first entry
is the current key and is used for all encryption; later entries are
decrypt-only previous keys, tried in order when the current key fails.
A plain string or a single-entry list builds the same single-key
Encrypter as before.

Empty later entries are ignored. An empty first entry throws rather than
promoting a previous key, so an unset env var cannot cause new data to be
encrypted with a retired key. Every key is validated at construction.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
Replace RotatingEncrypter with Cubex\Encryption\Encrypter, which extends
Illuminate's Encrypter and accepts either a key or a list of keys. The
first key encrypts and is tried first on decrypt; later keys are
decrypt-only fallbacks. EncryptionService always binds it, so string and
list config share one class and instanceof Illuminate's Encrypter holds.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
encryption_cipher defaults to AES-128-CBC. It is either one cipher for
every key or a list aligned by index with encryption_key, so a rotation
can move to AES-256-CBC while the previous key stays AES-128-CBC. A list
must have one entry per key entry, including empty ones; a list with a
single string key throws; an empty cipher entry is the default.

decrypt() now keeps the first failure while trying each key, so no catch
variable goes unused.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
@TomK TomK changed the title Support encryption key rotation via an encryption_key list Support encryption key rotation and cipher selection Sep 29, 2026
Cubex\Encryption\Encrypter is now standalone and implements
Illuminate\Contracts\Encryption\Encrypter, reading and writing the same
payload format as illuminate/encryption 5.x and throwing the contracts'
DecryptException/EncryptException. illuminate/contracts is now a direct
requirement; illuminate/encryption is require-dev only, for
compatibility tests in both directions.

Remove security.encryption_cipher: a 16 byte key uses AES-128-CBC and a
32 byte key AES-256-CBC, anything else throws. 16 byte keys behave as
before, and 32 byte keys previously threw under the default cipher.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
@TomK TomK changed the title Support encryption key rotation and cipher selection Encryption key rotation, AES-256 keys, drop illuminate/encryption Sep 29, 2026
@TomK

TomK commented Sep 29, 2026

Copy link
Copy Markdown
Contributor Author

Superseded by #73, which rebuilds this on v2-php74 (target 2.7.0) with a sodium-based format and a decrypt-only path for existing illuminate/encryption payloads. Closing rather than force-pushing so this history stays readable.

@TomK TomK closed this Sep 29, 2026
@TomK
TomK deleted the encryption-key-rotation branch September 29, 2026 12:21
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant