Skip to content

Encryption key rotation with XChaCha20-Poly1305 - #73

Merged
TomK merged 2 commits into
2.0from
encryption-sodium-2x
Sep 29, 2026
Merged

TomK merged 2 commits into
2.0from
encryption-sodium-2x

Conversation

@TomK

@TomK TomK commented Sep 29, 2026 •

Copy link
Copy Markdown
Contributor

encrypter is a single-key illuminate/encryption instance, so rotating [security] encryption_key (e.g. after a leak) makes every existing encrypted cookie undecryptable at once. Its AES-CBC + HMAC JSON payload is also large, and it unserializes decrypted data without restriction.

Adds Cubex\Encryption:

  • Encrypter implements EncrypterInterface (string in, string out) with XChaCha20-Poly1305. A payload is base64url of a version byte, a random 24-byte nonce and the ciphertext with its tag. The version byte is bound as additional data. The cipher key is derived with HKDF-SHA256 from each configured key, which must be at least 16 bytes.
  • Key list: encryption_key can now be a list. The first key encrypts; later keys only decrypt. An empty first entry throws, so an unset env var can't promote an old key. A missing item or [] keeps the existing default key.
  • Old cookies: Legacy\IlluminateFallbackEncrypter decrypts payloads written by Cubex 2.6, using AES-128/256-CBC chosen by key length. It never writes that format and can be deleted once old cookies have expired.
  • encrypter binding: now an IlluminateEncrypterAdapter implementing Illuminate's contract, so EncryptCookies keeps working. It unserializes with allowed_classes => false. EncrypterInterface is bound too.
  • Dependencies: illuminate/encryption moves to require-dev (compatibility tests only), and illuminate/contracts is required directly. Sodium is optional: ext-sodium or paragonie/sodium_compat is listed under suggest and checked only when an encrypter is constructed. There is no fallback algorithm.

Previous keys are normally compromised, so payloads they decrypt may be forged. Keep the window short, and consumers can re-issue cookies onto the new key. Payloads are roughly half the size: 45 bytes of plaintext becomes 115 characters instead of 244.

Test plan

New tests for Encrypter, the legacy fallback, the adapter and the service cover:

  • round trip, rotation, and wrong key
  • tampering with the version, nonce, ciphertext or tag
  • malformed payloads, and empty or short keys
  • missing sodium, simulated with a test subclass
  • real illuminate/encryption 5.5.44 AES-128 and AES-256 payloads (including serialized cookies) decrypting, and never being produced
  • tampered legacy payloads, and objects coming back as __PHP_Incomplete_Class without __wakeup

Ran vendor/bin/phpunit --coverage-text on PHP 7.4.33 with PHPUnit 9.6: 338 tests passed with 99.26% line coverage (threshold 98). The new classes have 100% coverage. The 9 createPartialMock warnings in CubexKernelTest also appear on 2.0 without this change.

Rollout

  1. Release 2.7.0 from 2.0 with gh release create.
  2. megameld bumps cubex/framework in every app that shares the cookie key, then sets encryption_key[] = {{ENV:COOKIE_ENCRYPTION_KEY}} followed by encryption_key[] = <old key>.
  3. Remove the old key after a short window.

🤖 Generated with Claude Code

TomK and others added 2 commits September 29, 2026 10:43
Add Cubex\Encryption: EncrypterInterface (string in, string out) and an
XChaCha20-Poly1305 implementation. Payloads are base64url of a version
byte, a random 24 byte nonce and the ciphertext with tag; the version is
bound as additional data. Cipher keys are derived with HKDF-SHA256 from
configured keys of at least 16 bytes.

security.encryption_key may be a list: the first key encrypts, later keys
only decrypt. An empty first entry throws instead of promoting an old key.

Payloads written by illuminate/encryption (Cubex 2.6) still decrypt
through Legacy\IlluminateFallbackEncrypter, which is decrypt-only and can
be removed once they have expired. 'encrypter' is now an adapter
implementing Illuminate's encrypter contract, unserializing with
allowed_classes disabled.

illuminate/encryption moves to require-dev for compatibility tests;
illuminate/contracts is required directly. Sodium is suggested, and
checked when an encrypter is constructed.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
Co-Authored-By: Claude Opus 5.5 <[email protected]>
@TomK
TomK force-pushed the encryption-sodium-2x branch from a5ac41f to 24187be Compare September 29, 2026 09:43
@TomK
TomK merged commit 1f25dce into 2.0 Sep 29, 2026
2 checks passed
@TomK
TomK deleted the encryption-sodium-2x branch September 29, 2026 11:17
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.

2 participants