diff --git a/app/Config/Encryption.php b/app/Config/Encryption.php index 28344134aa31..c6b8aade60ca 100644 --- a/app/Config/Encryption.php +++ b/app/Config/Encryption.php @@ -23,6 +23,23 @@ class Encryption extends BaseConfig */ public string $key = ''; + /** + * -------------------------------------------------------------------------- + * Previous Encryption Keys + * -------------------------------------------------------------------------- + * + * When rotating encryption keys, add old keys here to maintain ability + * to decrypt data encrypted with previous keys. Encryption always uses + * the current $key. Decryption tries current key first, then falls back + * to previous keys if decryption fails. + * + * In .env file, use comma-separated string: + * encryption.previousKeys = hex2bin:9be8c64fcea509867...,hex2bin:3f5a1d8e9c2b7a4f6... + * + * @var list|string + */ + public array|string $previousKeys = ''; + /** * -------------------------------------------------------------------------- * Encryption Driver to Use diff --git a/system/Config/BaseConfig.php b/system/Config/BaseConfig.php index ce6594d45d36..42da45cdb52f 100644 --- a/system/Config/BaseConfig.php +++ b/system/Config/BaseConfig.php @@ -130,18 +130,39 @@ public function __construct() foreach ($properties as $property) { $this->initEnvValue($this->{$property}, $property, $prefix, $shortPrefix); - if ($this instanceof Encryption && $property === 'key') { - if (str_starts_with($this->{$property}, 'hex2bin:')) { - // Handle hex2bin prefix - $this->{$property} = hex2bin(substr($this->{$property}, 8)); - } elseif (str_starts_with($this->{$property}, 'base64:')) { - // Handle base64 prefix - $this->{$property} = base64_decode(substr($this->{$property}, 7), true); + if ($this instanceof Encryption) { + if ($property === 'key') { + $this->{$property} = $this->parseEncryptionKey($this->{$property}); + } elseif ($property === 'previousKeys') { + $keysArray = is_string($this->{$property}) ? array_map(trim(...), explode(',', $this->{$property})) : $this->{$property}; + $parsedKeys = []; + + foreach ($keysArray as $key) { + $parsedKeys[] = $this->parseEncryptionKey($key); + } + + $this->{$property} = $parsedKeys; } } } } + /** + * Parse encryption key with hex2bin: or base64: prefix + */ + protected function parseEncryptionKey(string $key): string + { + if (str_starts_with($key, 'hex2bin:')) { + return hex2bin(substr($key, 8)); + } + + if (str_starts_with($key, 'base64:')) { + return base64_decode(substr($key, 7), true); + } + + return $key; + } + /** * Initialization an environment-specific configuration setting * diff --git a/system/Encryption/Encryption.php b/system/Encryption/Encryption.php index c233f1fdfe03..17a5b4c547a2 100644 --- a/system/Encryption/Encryption.php +++ b/system/Encryption/Encryption.php @@ -138,6 +138,10 @@ public function initialize(?EncryptionConfig $config = null) $handlerName = 'CodeIgniter\\Encryption\\Handlers\\' . $this->driver . 'Handler'; $this->encrypter = new $handlerName($config); + if (($config->previousKeys ?? []) !== []) { + $this->encrypter = new KeyRotationDecorator($this->encrypter, $config->previousKeys); + } + return $this->encrypter; } diff --git a/system/Encryption/Handlers/OpenSSLHandler.php b/system/Encryption/Handlers/OpenSSLHandler.php index 3df802c1b602..8e1be06d60ed 100644 --- a/system/Encryption/Handlers/OpenSSLHandler.php +++ b/system/Encryption/Handlers/OpenSSLHandler.php @@ -154,6 +154,12 @@ public function decrypt($data, #[SensitiveParameter] $params = null) // derive a secret key $encryptKey = \hash_hkdf($this->digest, $key, 0, $this->encryptKeyInfo); - return \openssl_decrypt($data, $this->cipher, $encryptKey, OPENSSL_RAW_DATA, $iv); + $result = \openssl_decrypt($data, $this->cipher, $encryptKey, OPENSSL_RAW_DATA, $iv); + + if ($result === false) { + throw EncryptionException::forAuthenticationFailed(); + } + + return $result; } } diff --git a/system/Encryption/KeyRotationDecorator.php b/system/Encryption/KeyRotationDecorator.php new file mode 100644 index 000000000000..8778b7ce800b --- /dev/null +++ b/system/Encryption/KeyRotationDecorator.php @@ -0,0 +1,111 @@ + + * + * For the full copyright and license information, please view + * the LICENSE file that was distributed with this source code. + */ + +namespace CodeIgniter\Encryption; + +use CodeIgniter\Encryption\Exceptions\EncryptionException; +use SensitiveParameter; + +/** + * Key Rotation Decorator + * + * Wraps any EncrypterInterface implementation to provide automatic + * fallback to previous encryption keys during decryption. This enables + * seamless key rotation without requiring re-encryption of existing data. + */ +class KeyRotationDecorator implements EncrypterInterface +{ + /** + * @param EncrypterInterface $innerHandler The wrapped encryption handler + * @param list $previousKeys Array of previous encryption keys + */ + public function __construct( + private readonly EncrypterInterface $innerHandler, + private readonly array $previousKeys, + ) { + } + + /** + * {@inheritDoc} + * + * Encryption always uses the inner handler's current key. + */ + public function encrypt(#[SensitiveParameter] $data, #[SensitiveParameter] $params = null) + { + return $this->innerHandler->encrypt($data, $params); + } + + /** + * {@inheritDoc} + * + * Attempts decryption with current key first. If that fails and no + * explicit key was provided in $params, tries each previous key. + * + * @throws EncryptionException + */ + public function decrypt($data, #[SensitiveParameter] $params = null) + { + try { + return $this->innerHandler->decrypt($data, $params); + } catch (EncryptionException $e) { + // Don't try previous keys if an explicit key was provided + if (is_string($params) || (is_array($params) && isset($params['key']))) { + throw $e; + } + + if ($this->previousKeys === []) { + throw $e; + } + + foreach ($this->previousKeys as $previousKey) { + try { + $previousParams = is_array($params) + ? array_merge($params, ['key' => $previousKey]) + : $previousKey; + + return $this->innerHandler->decrypt($data, $previousParams); + } catch (EncryptionException) { + continue; + } + } + + throw $e; + } + } + + /** + * Delegate property access to the inner handler. + * + * @return array|bool|int|string|null + */ + public function __get(string $key) + { + if (method_exists($this->innerHandler, '__get')) { + return $this->innerHandler->__get($key); + } + + return null; + } + + /** + * Delegate property existence check to inner handler. + */ + public function __isset(string $key): bool + { + if (method_exists($this->innerHandler, '__isset')) { + return $this->innerHandler->__isset($key); + } + + return false; + } +} diff --git a/tests/system/Encryption/KeyRotationDecoratorTest.php b/tests/system/Encryption/KeyRotationDecoratorTest.php new file mode 100644 index 000000000000..e94ea16bcd65 --- /dev/null +++ b/tests/system/Encryption/KeyRotationDecoratorTest.php @@ -0,0 +1,283 @@ + + * + * For the full copyright and license information, please view + * the LICENSE file that was distributed with this source code. + */ + +namespace CodeIgniter\Encryption; + +use CodeIgniter\Encryption\Exceptions\EncryptionException; +use CodeIgniter\Test\CIUnitTestCase; +use Config\Encryption as EncryptionConfig; +use PHPUnit\Framework\Attributes\Group; +use PHPUnit\Framework\Attributes\RequiresPhpExtension; + +/** + * @internal + */ +#[Group('Others')] +final class KeyRotationDecoratorTest extends CIUnitTestCase +{ + private Encryption $encryption; + + protected function setUp(): void + { + $this->encryption = new Encryption(); + } + + #[RequiresPhpExtension('openssl')] + public function testEncryptionUsesCurrentKey(): void + { + $currentKey = 'current-encryption-key'; + $previousKey = 'previous-encryption-key'; + + $params = new EncryptionConfig(); + $params->driver = 'OpenSSL'; + $params->key = $currentKey; + $params->previousKeys = [$previousKey]; + + $encrypter = $this->encryption->initialize($params); + + $message = 'This is a plain-text message.'; + $encrypted = $encrypter->encrypt($message); + + $this->assertSame($message, $encrypter->decrypt($encrypted)); + + $this->expectException(EncryptionException::class); + $encrypter->decrypt($encrypted, ['key' => $previousKey]); + } + + #[RequiresPhpExtension('openssl')] + public function testKeyRotationDecryptsOldData(): void + { + $oldKey = 'old-encryption-key'; + $newKey = 'new-encryption-key'; + + $paramsOld = new EncryptionConfig(); + $paramsOld->driver = 'OpenSSL'; + $paramsOld->key = $oldKey; + + $oldEncrypter = $this->encryption->initialize($paramsOld); + $message = 'Sensitive data encrypted with old key'; + $encrypted = $oldEncrypter->encrypt($message); + + $paramsNew = new EncryptionConfig(); + $paramsNew->driver = 'OpenSSL'; + $paramsNew->key = $newKey; + $paramsNew->previousKeys = [$oldKey]; + + $newEncrypter = $this->encryption->initialize($paramsNew); + + $this->assertSame($message, $newEncrypter->decrypt($encrypted)); + } + + #[RequiresPhpExtension('openssl')] + public function testMultiplePreviousKeysFallback(): void + { + $key1 = 'first-key-very-long'; + $key2 = 'second-key-very-long'; + $key3 = 'third-key-very-long'; + + $params1 = new EncryptionConfig(); + $params1->driver = 'OpenSSL'; + $params1->key = $key1; + $encrypter1 = $this->encryption->initialize($params1); + $message1 = 'Message encrypted with key1'; + $encrypted1 = $encrypter1->encrypt($message1); + + $params2 = new EncryptionConfig(); + $params2->driver = 'OpenSSL'; + $params2->key = $key2; + $encrypter2 = $this->encryption->initialize($params2); + $message2 = 'Message encrypted with key2'; + $encrypted2 = $encrypter2->encrypt($message2); + + $params3 = new EncryptionConfig(); + $params3->driver = 'OpenSSL'; + $params3->key = $key3; + $params3->previousKeys = [$key2, $key1]; + + $encrypter3 = $this->encryption->initialize($params3); + + $this->assertSame($message1, $encrypter3->decrypt($encrypted1)); + $this->assertSame($message2, $encrypter3->decrypt($encrypted2)); + } + + #[RequiresPhpExtension('openssl')] + public function testExplicitKeyPreventsRotation(): void + { + $currentKey = 'current-key-very-long'; + $previousKey = 'previous-key-very-long'; + $explicitKey = 'explicit-key-very-long'; + + $paramsOld = new EncryptionConfig(); + $paramsOld->driver = 'OpenSSL'; + $paramsOld->key = $previousKey; + $oldEncrypter = $this->encryption->initialize($paramsOld); + $message = 'Test message'; + $encrypted = $oldEncrypter->encrypt($message); + + $params = new EncryptionConfig(); + $params->driver = 'OpenSSL'; + $params->key = $currentKey; + $params->previousKeys = [$previousKey]; + $encrypter = $this->encryption->initialize($params); + + $this->expectException(EncryptionException::class); + $encrypter->decrypt($encrypted, ['key' => $explicitKey]); + } + + #[RequiresPhpExtension('openssl')] + public function testEmptyPreviousKeysNoFallback(): void + { + $key1 = 'first-key-very-long'; + $key2 = 'second-key-very-long'; + + $params1 = new EncryptionConfig(); + $params1->driver = 'OpenSSL'; + $params1->key = $key1; + $encrypter1 = $this->encryption->initialize($params1); + $message = 'Test message'; + $encrypted = $encrypter1->encrypt($message); + + $params2 = new EncryptionConfig(); + $params2->driver = 'OpenSSL'; + $params2->key = $key2; + $params2->previousKeys = []; + $encrypter2 = $this->encryption->initialize($params2); + + $this->expectException(EncryptionException::class); + $encrypter2->decrypt($encrypted); + } + + #[RequiresPhpExtension('openssl')] + public function testAllKeysFailThrowsOriginalException(): void + { + $correctKey = 'correct-key-very-long'; + $wrongKey1 = 'wrong-key-1-very-long'; + $wrongKey2 = 'wrong-key-2-very-long'; + $wrongKey3 = 'wrong-key-3-very-long'; + + $paramsCorrect = new EncryptionConfig(); + $paramsCorrect->driver = 'OpenSSL'; + $paramsCorrect->key = $correctKey; + $encrypter = $this->encryption->initialize($paramsCorrect); + $message = 'Test message'; + $encrypted = $encrypter->encrypt($message); + + $paramsWrong = new EncryptionConfig(); + $paramsWrong->driver = 'OpenSSL'; + $paramsWrong->key = $wrongKey1; + $paramsWrong->previousKeys = [$wrongKey2, $wrongKey3]; + $encrypterWrong = $this->encryption->initialize($paramsWrong); + + $this->expectException(EncryptionException::class); + $this->expectExceptionMessage('authentication failed'); + $encrypterWrong->decrypt($encrypted); + } + + #[RequiresPhpExtension('openssl')] + public function testPropertyAccessDelegation(): void + { + $params = new EncryptionConfig(); + $params->driver = 'OpenSSL'; + $params->key = 'test-key-very-long'; + $params->cipher = 'AES-128-CBC'; + $params->previousKeys = ['old-key']; + + $encrypter = $this->encryption->initialize($params); + + $this->assertSame('AES-128-CBC', $encrypter->cipher); + $this->assertSame('test-key-very-long', $encrypter->key); + } + + #[RequiresPhpExtension('sodium')] + public function testKeyRotationWithSodiumHandler(): void + { + $oldKey = sodium_crypto_secretbox_keygen(); + $newKey = sodium_crypto_secretbox_keygen(); + + $paramsOld = new EncryptionConfig(); + $paramsOld->driver = 'Sodium'; + $paramsOld->key = $oldKey; + $oldEncrypter = $this->encryption->initialize($paramsOld); + $message = 'Sensitive data encrypted with old Sodium key'; + $encrypted = $oldEncrypter->encrypt($message); + + $paramsNew = new EncryptionConfig(); + $paramsNew->driver = 'Sodium'; + $paramsNew->key = $newKey; + $paramsNew->previousKeys = [$oldKey]; + $newEncrypter = $this->encryption->initialize($paramsNew); + + $this->assertSame($message, $newEncrypter->decrypt($encrypted)); + + $newMessage = 'New message with new key'; + $newEncrypted = $newEncrypter->encrypt($newMessage); + $this->assertSame($newMessage, $newEncrypter->decrypt($newEncrypted)); + } + + #[RequiresPhpExtension('openssl')] + public function testRealisticKeyRotationScenario(): void + { + $q1Key = 'q1-2026-key-very-long'; + $q2Key = 'q2-2026-key-very-long'; + $q3Key = 'q3-2026-key-very-long'; + $q4Key = 'q4-2026-key-very-long'; + + // Q1: Encrypt user data + $configQ1 = new EncryptionConfig(); + $configQ1->driver = 'OpenSSL'; + $configQ1->key = $q1Key; + $encrypterQ1 = $this->encryption->initialize($configQ1); + $userData = 'user-sensitive-data-from-q1'; + $encryptedQ1 = $encrypterQ1->encrypt($userData); + + // Q2: Rotate to new key, keep Q1 for BC + $configQ2 = new EncryptionConfig(); + $configQ2->driver = 'OpenSSL'; + $configQ2->key = $q2Key; + $configQ2->previousKeys = [$q1Key]; + $encrypterQ2 = $this->encryption->initialize($configQ2); + + // Can still read Q1 data + $this->assertSame($userData, $encrypterQ2->decrypt($encryptedQ1)); + + // New data encrypted with Q2 key + $newData = 'user-sensitive-data-from-q2'; + $encryptedQ2 = $encrypterQ2->encrypt($newData); + $this->assertSame($newData, $encrypterQ2->decrypt($encryptedQ2)); + + // Q3: Rotate to new key, keep Q2 and Q1 for BC + $configQ3 = new EncryptionConfig(); + $configQ3->driver = 'OpenSSL'; + $configQ3->key = $q3Key; + $configQ3->previousKeys = [$q2Key, $q1Key]; + $encrypterQ3 = $this->encryption->initialize($configQ3); + + // Can still read Q1 and Q2 data + $this->assertSame($userData, $encrypterQ3->decrypt($encryptedQ1)); + $this->assertSame($newData, $encrypterQ3->decrypt($encryptedQ2)); + + // Q4: Rotate to new key, keep only Q3 and Q2 (drop Q1 - data should be re-encrypted by now) + $configQ4 = new EncryptionConfig(); + $configQ4->driver = 'OpenSSL'; + $configQ4->key = $q4Key; + $configQ4->previousKeys = [$q3Key, $q2Key]; + $encrypterQ4 = $this->encryption->initialize($configQ4); + + // Can still read Q2 and Q3 data + $this->assertSame($newData, $encrypterQ4->decrypt($encryptedQ2)); + + // But Q1 data is no longer accessible (as intended) + $this->expectException(EncryptionException::class); + $encrypterQ4->decrypt($encryptedQ1); + } +} diff --git a/user_guide_src/source/changelogs/v4.7.0.rst b/user_guide_src/source/changelogs/v4.7.0.rst index f8e11e1a9608..c4dcb7d7d40f 100644 --- a/user_guide_src/source/changelogs/v4.7.0.rst +++ b/user_guide_src/source/changelogs/v4.7.0.rst @@ -241,6 +241,7 @@ Libraries - **CURLRequest:** Added ``fresh_connect`` options to enable/disable request fresh connection. - **DataConverter:** Added ``EnumCast`` caster for database and entity. - **Email:** Added support for choosing the SMTP authorization method. You can change it via ``Config\Email::$SMTPAuthMethod`` option. +- **Encryption:** Added ``Config\Encryption::$previousKeys`` configuration option to support encryption key rotation. When decryption with the current key fails, the system automatically falls back to previous keys, allowing you to rotate encryption keys without losing access to old encrypted data. - **Image:** The ``ImageMagickHandler`` has been rewritten to rely solely on the PHP ``imagick`` extension. - **Image:** Added ``ImageMagickHandler::clearMetadata()`` method to remove image metadata for privacy protection. - **ResponseTrait:** Added ``paginate``` method to simplify paginated API responses. See :ref:`ResponseTrait::paginate() ` for details. diff --git a/user_guide_src/source/libraries/encryption.rst b/user_guide_src/source/libraries/encryption.rst index 27192550ae1d..bbdfa1e053c9 100644 --- a/user_guide_src/source/libraries/encryption.rst +++ b/user_guide_src/source/libraries/encryption.rst @@ -177,6 +177,55 @@ Similarly, you can use these prefixes in your **.env** file, too! // or encryption.key = base64: +Encryption Key Rotation +======================= + +.. versionadded:: 4.7.0 + +When you need to rotate your encryption key (for security best practices or compliance requirements), +you can use the ``previousKeys`` configuration option to maintain the ability to decrypt data encrypted +with old keys while using a new key for all new encryption operations. + +How It Works +------------ + +- **Encryption** always uses the current ``key`` value +- **Decryption** tries the current ``key`` first +- If decryption fails, it automatically falls back to trying each key in ``previousKeys`` +- This allows seamless key rotation without data loss + +Configuration +------------- + +Add your old keys to the ``$previousKeys`` property in **app/Config/Encryption.php**: + +.. literalinclude:: encryption/014.php + +Using .env File +--------------- + +You can also configure previous keys in your **.env** file (recommended) using a comma-separated list: + +:: + + encryption.key = hex2bin:your_new_key + encryption.previousKeys = hex2bin:old_key_1,hex2bin:old_key_2 + +The framework automatically parses the comma-separated string into an array and processes each key. + +Key Rotation Workflow +--------------------- + +1. **Before rotation**: Data is encrypted and decrypted with ``key`` +2. **Start rotation**: Move current ``key`` value to ``previousKeys`` array, set new value for ``key`` +3. **During rotation**: New data encrypted with new ``key``, old data still decryptable via ``previousKeys`` +4. **Re-encrypt data** (optional): Decrypt and re-encrypt existing data with the new key +5. **Complete rotation**: Once all data is re-encrypted, remove old keys from ``previousKeys`` + +.. important:: The ``previousKeys`` feature is for **decryption fallback only**. All new encryption + operations always use the current ``key``. If you pass an explicit key via the ``$params`` + argument to ``encrypt()`` or ``decrypt()``, the previousKeys fallback will not be used. + Padding ======= diff --git a/user_guide_src/source/libraries/encryption/014.php b/user_guide_src/source/libraries/encryption/014.php new file mode 100644 index 000000000000..77a2255419ab --- /dev/null +++ b/user_guide_src/source/libraries/encryption/014.php @@ -0,0 +1,17 @@ +