From 14aa07b567f6b88ccf0960de1da94c7a820b2b53 Mon Sep 17 00:00:00 2001 From: michalsn Date: Thu, 25 Dec 2025 23:04:43 +0100 Subject: [PATCH 1/2] feat: encryption previousKeys support Co-authored-by: patel-vansh --- app/Config/Encryption.php | 17 +++ system/Config/BaseConfig.php | 35 ++++-- system/Encryption/Handlers/BaseHandler.php | 49 ++++++++ system/Encryption/Handlers/OpenSSLHandler.php | 76 ++++++------ system/Encryption/Handlers/SodiumHandler.php | 80 +++++++----- .../Encryption/ConfigWithPreviousKeys.php | 35 ++++++ tests/system/Debug/ToolbarTest.php | 4 +- .../Handlers/OpenSSLHandlerTest.php | 95 +++++++++++++++ .../Encryption/Handlers/SodiumHandlerTest.php | 115 +++++++++++++++++- user_guide_src/source/changelogs/v4.7.0.rst | 58 +++++++++ .../source/libraries/encryption.rst | 55 ++++++++- .../source/libraries/encryption/014.php | 17 +++ 12 files changed, 550 insertions(+), 86 deletions(-) create mode 100644 tests/_support/Encryption/ConfigWithPreviousKeys.php create mode 100644 user_guide_src/source/libraries/encryption/014.php diff --git a/app/Config/Encryption.php b/app/Config/Encryption.php index 28344134aa31..10977c01b3ae 100644 --- a/app/Config/Encryption.php +++ b/app/Config/Encryption.php @@ -89,4 +89,21 @@ class Encryption extends BaseConfig * by CI3 Encryption default configuration. */ public string $cipher = 'AES-256-CTR'; + + /** + * -------------------------------------------------------------------------- + * 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 = ''; } 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/Handlers/BaseHandler.php b/system/Encryption/Handlers/BaseHandler.php index 365ed961d323..30bc2c2d9e3a 100644 --- a/system/Encryption/Handlers/BaseHandler.php +++ b/system/Encryption/Handlers/BaseHandler.php @@ -14,13 +14,22 @@ namespace CodeIgniter\Encryption\Handlers; use CodeIgniter\Encryption\EncrypterInterface; +use CodeIgniter\Encryption\Exceptions\EncryptionException; use Config\Encryption; +use SensitiveParameter; /** * Base class for encryption handling */ abstract class BaseHandler implements EncrypterInterface { + /** + * Previous encryption keys for decryption fallback + * + * @var list + */ + protected array $previousKeys = []; + /** * Constructor */ @@ -50,6 +59,46 @@ protected static function substr($str, $start, $length = null) return mb_substr($str, $start, $length, '8bit'); } + /** + * Try to decrypt data with fallback to previous keys + * + * @param string $data Data to decrypt + * @param array|string|null $params Overridden parameters, specifically the key + * @param callable(string, array|string|null): string $decryptCallback Callback that performs the actual decryption + * + * @return string + * + * @throws EncryptionException + */ + protected function tryDecryptWithFallback($data, #[SensitiveParameter] $params, callable $decryptCallback) + { + try { + return $decryptCallback($data, $params); + } catch (EncryptionException $e) { + if ($this->previousKeys === []) { + throw $e; + } + + if (is_string($params) || (is_array($params) && isset($params['key']))) { + throw $e; + } + + foreach ($this->previousKeys as $previousKey) { + try { + $previousParams = is_array($params) + ? array_merge($params, ['key' => $previousKey]) + : $previousKey; + + return $decryptCallback($data, $previousParams); + } catch (EncryptionException) { + continue; + } + } + + throw $e; + } + } + /** * __get() magic, providing readonly access to some of our properties * diff --git a/system/Encryption/Handlers/OpenSSLHandler.php b/system/Encryption/Handlers/OpenSSLHandler.php index 9dca5b90304c..41633c3942b6 100644 --- a/system/Encryption/Handlers/OpenSSLHandler.php +++ b/system/Encryption/Handlers/OpenSSLHandler.php @@ -82,17 +82,16 @@ class OpenSSLHandler extends BaseHandler */ public function encrypt(#[SensitiveParameter] $data, #[SensitiveParameter] $params = null) { - // Allow key override - if ($params !== null) { - $this->key = is_array($params) && isset($params['key']) ? $params['key'] : $params; - } + $key = $params !== null + ? (is_array($params) && isset($params['key']) ? $params['key'] : $params) + : $this->key; - if (empty($this->key)) { + if (empty($key)) { throw EncryptionException::forNeedsStarterKey(); } // derive a secret key - $encryptKey = \hash_hkdf($this->digest, $this->key, 0, $this->encryptKeyInfo); + $encryptKey = \hash_hkdf($this->digest, $key, 0, $this->encryptKeyInfo); // basic encryption $iv = ($ivSize = \openssl_cipher_iv_length($this->cipher)) ? \openssl_random_pseudo_bytes($ivSize) : null; @@ -106,7 +105,7 @@ public function encrypt(#[SensitiveParameter] $data, #[SensitiveParameter] $para $result = $this->rawData ? $iv . $data : base64_encode($iv . $data); // derive a secret key - $authKey = \hash_hkdf($this->digest, $this->key, 0, $this->authKeyInfo); + $authKey = \hash_hkdf($this->digest, $key, 0, $this->authKeyInfo); $hmacKey = \hash_hmac($this->digest, $result, $authKey, $this->rawData); @@ -118,42 +117,49 @@ public function encrypt(#[SensitiveParameter] $data, #[SensitiveParameter] $para */ public function decrypt($data, #[SensitiveParameter] $params = null) { - // Allow key override - if ($params !== null) { - $this->key = is_array($params) && isset($params['key']) ? $params['key'] : $params; - } + return $this->tryDecryptWithFallback($data, $params, function ($data, $params): string { + $key = $params !== null + ? (is_array($params) && isset($params['key']) ? $params['key'] : $params) + : $this->key; - if (empty($this->key)) { - throw EncryptionException::forNeedsStarterKey(); - } + if (empty($key)) { + throw EncryptionException::forNeedsStarterKey(); + } - // derive a secret key - $authKey = \hash_hkdf($this->digest, $this->key, 0, $this->authKeyInfo); + // derive a secret key + $authKey = \hash_hkdf($this->digest, $key, 0, $this->authKeyInfo); - $hmacLength = $this->rawData - ? $this->digestSize[$this->digest] - : $this->digestSize[$this->digest] * 2; + $hmacLength = $this->rawData + ? $this->digestSize[$this->digest] + : $this->digestSize[$this->digest] * 2; - $hmacKey = self::substr($data, 0, $hmacLength); - $data = self::substr($data, $hmacLength); - $hmacCalc = \hash_hmac($this->digest, $data, $authKey, $this->rawData); + $hmacKey = self::substr($data, 0, $hmacLength); + $data = self::substr($data, $hmacLength); + $hmacCalc = \hash_hmac($this->digest, $data, $authKey, $this->rawData); - if (! hash_equals($hmacKey, $hmacCalc)) { - throw EncryptionException::forAuthenticationFailed(); - } + if (! hash_equals($hmacKey, $hmacCalc)) { + throw EncryptionException::forAuthenticationFailed(); + } - $data = $this->rawData ? $data : base64_decode($data, true); + $data = $this->rawData ? $data : base64_decode($data, true); - if ($ivSize = \openssl_cipher_iv_length($this->cipher)) { - $iv = self::substr($data, 0, $ivSize); - $data = self::substr($data, $ivSize); - } else { - $iv = null; - } + if ($ivSize = \openssl_cipher_iv_length($this->cipher)) { + $iv = self::substr($data, 0, $ivSize); + $data = self::substr($data, $ivSize); + } else { + $iv = null; + } - // derive a secret key - $encryptKey = \hash_hkdf($this->digest, $this->key, 0, $this->encryptKeyInfo); + // derive a secret key + $encryptKey = \hash_hkdf($this->digest, $key, 0, $this->encryptKeyInfo); + + $result = \openssl_decrypt($data, $this->cipher, $encryptKey, OPENSSL_RAW_DATA, $iv); + + if ($result === false) { + throw EncryptionException::forAuthenticationFailed(); + } - return \openssl_decrypt($data, $this->cipher, $encryptKey, OPENSSL_RAW_DATA, $iv); + return $result; + }); } } diff --git a/system/Encryption/Handlers/SodiumHandler.php b/system/Encryption/Handlers/SodiumHandler.php index 45f9ac2fa383..b810b9da7aad 100644 --- a/system/Encryption/Handlers/SodiumHandler.php +++ b/system/Encryption/Handlers/SodiumHandler.php @@ -43,9 +43,15 @@ class SodiumHandler extends BaseHandler */ public function encrypt(#[SensitiveParameter] $data, #[SensitiveParameter] $params = null) { - $this->parseParams($params); + $key = $params !== null + ? (is_array($params) && isset($params['key']) ? $params['key'] : $params) + : $this->key; - if (empty($this->key)) { + $blockSize = is_array($params) && isset($params['blockSize']) + ? $params['blockSize'] + : $this->blockSize; + + if (empty($key)) { throw EncryptionException::forNeedsStarterKey(); } @@ -53,18 +59,18 @@ public function encrypt(#[SensitiveParameter] $data, #[SensitiveParameter] $para $nonce = random_bytes(SODIUM_CRYPTO_SECRETBOX_NONCEBYTES); // 24 bytes // add padding before we encrypt the data - if ($this->blockSize <= 0) { + if ($blockSize <= 0) { throw EncryptionException::forEncryptionFailed(); } - $data = sodium_pad($data, $this->blockSize); + $data = sodium_pad($data, $blockSize); // encrypt message and combine with nonce - $ciphertext = $nonce . sodium_crypto_secretbox($data, $nonce, $this->key); + $ciphertext = $nonce . sodium_crypto_secretbox($data, $nonce, $key); // cleanup buffers sodium_memzero($data); - sodium_memzero($this->key); + sodium_memzero($key); return $ciphertext; } @@ -74,41 +80,49 @@ public function encrypt(#[SensitiveParameter] $data, #[SensitiveParameter] $para */ public function decrypt($data, #[SensitiveParameter] $params = null) { - $this->parseParams($params); + return $this->tryDecryptWithFallback($data, $params, function ($data, $params): string { + $key = $params !== null + ? (is_array($params) && isset($params['key']) ? $params['key'] : $params) + : $this->key; - if (empty($this->key)) { - throw EncryptionException::forNeedsStarterKey(); - } + $blockSize = is_array($params) && isset($params['blockSize']) + ? $params['blockSize'] + : $this->blockSize; - if (mb_strlen($data, '8bit') < (SODIUM_CRYPTO_SECRETBOX_NONCEBYTES + SODIUM_CRYPTO_SECRETBOX_MACBYTES)) { - // message was truncated - throw EncryptionException::forAuthenticationFailed(); - } + if (empty($key)) { + throw EncryptionException::forNeedsStarterKey(); + } - // Extract info from encrypted data - $nonce = self::substr($data, 0, SODIUM_CRYPTO_SECRETBOX_NONCEBYTES); - $ciphertext = self::substr($data, SODIUM_CRYPTO_SECRETBOX_NONCEBYTES); + if (mb_strlen($data, '8bit') < (SODIUM_CRYPTO_SECRETBOX_NONCEBYTES + SODIUM_CRYPTO_SECRETBOX_MACBYTES)) { + // message was truncated + throw EncryptionException::forAuthenticationFailed(); + } - // decrypt data - $data = sodium_crypto_secretbox_open($ciphertext, $nonce, $this->key); + // Extract info from encrypted data + $nonce = self::substr($data, 0, SODIUM_CRYPTO_SECRETBOX_NONCEBYTES); + $ciphertext = self::substr($data, SODIUM_CRYPTO_SECRETBOX_NONCEBYTES); - if ($data === false) { - // message was tampered in transit - throw EncryptionException::forAuthenticationFailed(); // @codeCoverageIgnore - } + // decrypt data + $data = sodium_crypto_secretbox_open($ciphertext, $nonce, $key); - // remove extra padding during encryption - if ($this->blockSize <= 0) { - throw EncryptionException::forAuthenticationFailed(); - } + if ($data === false) { + // message was tampered in transit + throw EncryptionException::forAuthenticationFailed(); // @codeCoverageIgnore + } + + // remove extra padding during encryption + if ($blockSize <= 0) { + throw EncryptionException::forAuthenticationFailed(); + } - $data = sodium_unpad($data, $this->blockSize); + $data = sodium_unpad($data, $blockSize); - // cleanup buffers - sodium_memzero($ciphertext); - sodium_memzero($this->key); + // cleanup buffers + sodium_memzero($ciphertext); + sodium_memzero($key); - return $data; + return $data; + }); } /** @@ -119,6 +133,8 @@ public function decrypt($data, #[SensitiveParameter] $params = null) * @return void * * @throws EncryptionException If key is empty + * + * @deprecated 4.7.0 No longer used. */ protected function parseParams($params) { diff --git a/tests/_support/Encryption/ConfigWithPreviousKeys.php b/tests/_support/Encryption/ConfigWithPreviousKeys.php new file mode 100644 index 000000000000..c9e80b7814f5 --- /dev/null +++ b/tests/_support/Encryption/ConfigWithPreviousKeys.php @@ -0,0 +1,35 @@ + + * + * For the full copyright and license information, please view + * the LICENSE file that was distributed with this source code. + */ + +namespace Tests\Support\Encryption; + +use Config\Encryption as BaseEncryption; + +/** + * Encryption config for testing previousKeys functionality + */ +class ConfigWithPreviousKeys extends BaseEncryption +{ + public string $driver = 'OpenSSL'; + public string $key = 'current-encryption-key-for-testing'; + + /** + * Previous encryption keys for decryption fallback + * + * @var list|string + */ + public array|string $previousKeys = [ + 'old-key-1', + 'old-key-2', + ]; +} diff --git a/tests/system/Debug/ToolbarTest.php b/tests/system/Debug/ToolbarTest.php index 16dceb943536..8d653cd263dc 100644 --- a/tests/system/Debug/ToolbarTest.php +++ b/tests/system/Debug/ToolbarTest.php @@ -39,7 +39,7 @@ protected function setUp(): void parent::setUp(); Services::reset(); - is_cli(false); + is_cli(false); // @phpstan-ignore arguments.count $this->config = new ToolbarConfig(); @@ -55,7 +55,7 @@ protected function setUp(): void protected function tearDown(): void { // Restore is_cli state - is_cli(true); + is_cli(true); // @phpstan-ignore arguments.count parent::tearDown(); } diff --git a/tests/system/Encryption/Handlers/OpenSSLHandlerTest.php b/tests/system/Encryption/Handlers/OpenSSLHandlerTest.php index f82e9151cd3c..11f880d324a5 100644 --- a/tests/system/Encryption/Handlers/OpenSSLHandlerTest.php +++ b/tests/system/Encryption/Handlers/OpenSSLHandlerTest.php @@ -18,6 +18,7 @@ use CodeIgniter\Test\CIUnitTestCase; use Config\Encryption as EncryptionConfig; use PHPUnit\Framework\Attributes\Group; +use Tests\Support\Encryption\ConfigWithPreviousKeys; /** * @internal @@ -97,6 +98,28 @@ public function testWithKeyString(): void $this->assertSame($message1, $encrypter->decrypt($encoded, $key)); } + public function testHandlerCanBeReusedAfterEncryption(): void + { + $params = new EncryptionConfig(); + $params->driver = 'OpenSSL'; + $params->key = '\xd0\xc9\x08\xc4\xde\x52\x12\x6e\xf8\xcc\xdb\x03\xea\xa0\x3a\x5c'; + + $encrypter = $this->encryption->initialize($params); + $message = 'Some message to encrypt'; + + $ciphertext = $encrypter->encrypt($message); + $plaintext = $encrypter->decrypt($ciphertext); + + $this->assertSame($message, $plaintext); + + // Should also work for another encryption + $message2 = 'Another message'; + $ciphertext2 = $encrypter->encrypt($message2); + $plaintext2 = $encrypter->decrypt($ciphertext2); + + $this->assertSame($message2, $plaintext2); + } + /** * Authentication will fail decrypting with the wrong key */ @@ -137,4 +160,76 @@ public function testWithWrongKeyArray(): void $key2 = 'Holy cow, batman!'; $this->assertNotSame($message1, $encrypter->decrypt($encoded, ['key' => $key2])); } + + public function testDecryptWithPreviousKeys(): void + { + $config = new ConfigWithPreviousKeys(); + $config->driver = 'OpenSSL'; + + $encrypter = $this->encryption->initialize($config); + + $message = 'Secret message'; + + // Encrypt with old key + $encrypted = $encrypter->encrypt($message, 'old-key-1'); + + // Decrypt without providing key - should use config key and fall back to previousKeys + $decrypted = $encrypter->decrypt($encrypted); + + $this->assertSame($message, $decrypted); + } + + public function testDecryptWithPreviousKeysOrder(): void + { + $config = new ConfigWithPreviousKeys(); + $config->driver = 'OpenSSL'; + + $encrypter = $this->encryption->initialize($config); + + $message = 'Secret message'; + + // Encrypt with second old key + $encrypted = $encrypter->encrypt($message, 'old-key-2'); + + // Should successfully decrypt using second previousKey + $decrypted = $encrypter->decrypt($encrypted); + + $this->assertSame($message, $decrypted); + } + + public function testDecryptWithExplicitKeyDoesNotUsePreviousKeys(): void + { + $this->expectException(EncryptionException::class); + + $config = new ConfigWithPreviousKeys(); + $config->driver = 'OpenSSL'; + + $encrypter = $this->encryption->initialize($config); + + $message = 'Secret message'; + + // Encrypt with old key + $encrypted = $encrypter->encrypt($message, 'old-key-1'); + + // Try to decrypt with explicit wrong key - should NOT fall back to previousKeys + $encrypter->decrypt($encrypted, 'wrong-key'); + } + + public function testDecryptWithExplicitKeyArrayDoesNotUsePreviousKeys(): void + { + $this->expectException(EncryptionException::class); + + $config = new ConfigWithPreviousKeys(); + $config->driver = 'OpenSSL'; + + $encrypter = $this->encryption->initialize($config); + + $message = 'Secret message'; + + // Encrypt with old key + $encrypted = $encrypter->encrypt($message, 'old-key-1'); + + // Try to decrypt with explicit wrong key in array - should NOT fall back to previousKeys + $encrypter->decrypt($encrypted, ['key' => 'wrong-key']); + } } diff --git a/tests/system/Encryption/Handlers/SodiumHandlerTest.php b/tests/system/Encryption/Handlers/SodiumHandlerTest.php index bf30036eb2a4..e8c372eb603f 100644 --- a/tests/system/Encryption/Handlers/SodiumHandlerTest.php +++ b/tests/system/Encryption/Handlers/SodiumHandlerTest.php @@ -18,6 +18,7 @@ use CodeIgniter\Test\CIUnitTestCase; use Config\Encryption as EncryptionConfig; use PHPUnit\Framework\Attributes\Group; +use Tests\Support\Encryption\ConfigWithPreviousKeys; /** * @internal @@ -78,14 +79,22 @@ public function testInvalidBlockSizeThrowsErrorOnEncrypt(): void $encrypter->encrypt('Some message.'); } - public function testEmptyKeyThrowsErrorOnDecrypt(): void + public function testHandlerCanBeReusedAfterEncryption(): void { - $this->expectException(EncryptionException::class); + $encrypter = $this->encryption->initialize($this->config); + $message = 'Some message to encrypt'; - $encrypter = $this->encryption->initialize($this->config); - $ciphertext = $encrypter->encrypt('Some message to encrypt'); - // After encrypt, the message and key are wiped from buffer - $encrypter->decrypt($ciphertext); + $ciphertext = $encrypter->encrypt($message); + $plaintext = $encrypter->decrypt($ciphertext); + + $this->assertSame($message, $plaintext); + + // Should also work for another encryption + $message2 = 'Another message'; + $ciphertext2 = $encrypter->encrypt($message2); + $plaintext2 = $encrypter->decrypt($ciphertext2); + + $this->assertSame($message2, $plaintext2); } public function testInvalidBlockSizeThrowsErrorOnDecrypt(): void @@ -121,4 +130,98 @@ public function testDecryptingMessages(): void $this->assertSame($msg, $encrypter->decrypt($ciphertext, $key)); $this->assertNotSame('A plain-text message for you.', $encrypter->decrypt($ciphertext, $key)); } + + public function testDecryptWithPreviousKeys(): void + { + $oldKey1 = sodium_crypto_secretbox_keygen(); + $oldKey2 = sodium_crypto_secretbox_keygen(); + + $config = new ConfigWithPreviousKeys(); + $config->driver = 'Sodium'; + $config->key = sodium_crypto_secretbox_keygen(); + $config->previousKeys = [$oldKey1, $oldKey2]; + + $encrypter = $this->encryption->initialize($config); + + $message = 'Secret message'; + + // Encrypt with old key + $encrypted = $encrypter->encrypt($message, $oldKey1); + + // Decrypt without providing key - should use config key and fall back to previousKeys + $decrypted = $encrypter->decrypt($encrypted); + + $this->assertSame($message, $decrypted); + } + + public function testDecryptWithPreviousKeysOrder(): void + { + $oldKey1 = sodium_crypto_secretbox_keygen(); + $oldKey2 = sodium_crypto_secretbox_keygen(); + + $config = new ConfigWithPreviousKeys(); + $config->driver = 'Sodium'; + $config->key = sodium_crypto_secretbox_keygen(); + $config->previousKeys = [$oldKey1, $oldKey2]; + + $encrypter = $this->encryption->initialize($config); + + $message = 'Secret message'; + + // Encrypt with second old key + $encrypted = $encrypter->encrypt($message, $oldKey2); + + // Should successfully decrypt using second previousKey + $decrypted = $encrypter->decrypt($encrypted); + + $this->assertSame($message, $decrypted); + } + + public function testDecryptWithExplicitKeyDoesNotUsePreviousKeys(): void + { + $this->expectException(EncryptionException::class); + + $oldKey1 = sodium_crypto_secretbox_keygen(); + $oldKey2 = sodium_crypto_secretbox_keygen(); + + $config = new ConfigWithPreviousKeys(); + $config->driver = 'Sodium'; + $config->key = sodium_crypto_secretbox_keygen(); + $config->previousKeys = [$oldKey1, $oldKey2]; + + $encrypter = $this->encryption->initialize($config); + + $message = 'Secret message'; + + // Encrypt with old key + $encrypted = $encrypter->encrypt($message, $oldKey1); + + // Try to decrypt with explicit wrong key - should NOT fall back to previousKeys + $wrongKey = sodium_crypto_secretbox_keygen(); + $encrypter->decrypt($encrypted, $wrongKey); + } + + public function testDecryptWithExplicitKeyArrayDoesNotUsePreviousKeys(): void + { + $this->expectException(EncryptionException::class); + + $oldKey1 = sodium_crypto_secretbox_keygen(); + $oldKey2 = sodium_crypto_secretbox_keygen(); + + $config = new ConfigWithPreviousKeys(); + $config->driver = 'Sodium'; + $config->key = sodium_crypto_secretbox_keygen(); + $config->previousKeys = [$oldKey1, $oldKey2]; + + $encrypter = $this->encryption->initialize($config); + + $message = 'Secret message'; + + // Encrypt with old key + $encrypted = $encrypter->encrypt($message, $oldKey1); + + // Try to decrypt with explicit wrong key in array - should NOT fall back to previousKeys + $wrongKey = sodium_crypto_secretbox_keygen(); + $encrypter->decrypt($encrypted, ['key' => $wrongKey]); + } } diff --git a/user_guide_src/source/changelogs/v4.7.0.rst b/user_guide_src/source/changelogs/v4.7.0.rst index 19013de5cff8..dafcdd249714 100644 --- a/user_guide_src/source/changelogs/v4.7.0.rst +++ b/user_guide_src/source/changelogs/v4.7.0.rst @@ -111,6 +111,61 @@ parameter is ``true``. Previously, properties containing arrays were not recursi If you were relying on the old behavior where arrays remained unconverted, you will need to update your code. +Encryption Handlers +------------------- + +The ``OpenSSLHandler`` and ``SodiumHandler`` no longer modify the handler's ``$key`` property +when encryption/decryption parameters are passed via the ``$params`` argument. Keys passed through +``$params`` are now used as local variables, ensuring the handler's state remains unchanged. + +**What changed:** + +- Previously, passing a key via ``$params`` to ``encrypt()`` or ``decrypt()`` would permanently + modify the handler's internal ``$key`` property. +- Now, the handler's ``$key`` property is only set during handler creation via ``Config\Encryption``. + Passing keys through ``$params`` uses them as temporary local variables without modifying the handler's state. +- ``SodiumHandler::encrypt()`` no longer calls ``sodium_memzero($this->key)``, which previously + destroyed the encryption key after the first use, preventing handler reuse. + +**Impact:** + +**You are only affected if** you passed a key via ``$params`` to ``encrypt()`` or ``decrypt()`` +and expected that the ``key`` will persist for subsequent operations. Most users are **not affected**: + +- **Not affected:** You always pass the key via ``$params`` for each operation +- **Not affected:** You never use ``$params`` and always configure keys via ``Config\Encryption`` +- **Affected:** You passed a key via ``$params`` once and expected it to be remembered + +If affected, configure the key properly via ``Config\Encryption`` or pass a custom config to the +service instead of relying on ``$params`` side effects. + +**Example of affected code:** + +.. code-block:: php + + $config = config('Encryption'); + $config->key = 'your-encryption-key'; + $handler = service('encrypter', $config); + $handler->encrypt($data, 'temporary-key'); + // Old: $handler->key is now 'temporary-key' + // New: $handler->key remains unchanged ('your-encryption-key') + + $handler->encrypt($moreData); + // Old: Would use 'temporary-key' + // New: Uses default key ('your-encryption-key') + +**Migration:** + +To use a different encryption key permanently, pass a custom config when creating the service: + +.. code-block:: php + + $config = config('Encryption'); + $config->key = 'your-custom-encryption-key'; + + // Get a new handler instance with the custom config (not shared) + $handler = service('encrypter', $config, false); + Interface Changes ================= @@ -186,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 ``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. Configure via ``Config\Encryption::$previousKeys``. - **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. @@ -252,6 +308,8 @@ Changes Deprecations ************ +- **Encryption:** + - The method ``CodeIgniter\Encryption\Handlers\SodiumHandler::parseParams()`` has been deprecated. Parameters are now handled directly in ``encrypt()`` and ``decrypt()`` methods. - **Image:** - The config property ``Config\Image::libraryPath`` has been deprecated. No longer used. - The exception method ``CodeIgniter\Images\Exceptions\ImageException::forInvalidImageLibraryPath`` has been deprecated. No longer used. diff --git a/user_guide_src/source/libraries/encryption.rst b/user_guide_src/source/libraries/encryption.rst index 8c7a200d94eb..0ee22a8f2368 100644 --- a/user_guide_src/source/libraries/encryption.rst +++ b/user_guide_src/source/libraries/encryption.rst @@ -65,6 +65,8 @@ The example above uses the configuration settings found in **app/Config/Encrypti Option Possible values (default in parentheses) ============== ========================================================================== key Encryption key starter +previousKeys Comma-separated list (or array) of previous encryption keys for decryption + fallback (``''``) driver Preferred handler, e.g., OpenSSL or Sodium (``OpenSSL``) digest Message digest algorithm (``SHA512``) blockSize [**SodiumHandler** only] Padding length in bytes (``16``) @@ -177,6 +179,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 ======= @@ -223,10 +274,6 @@ sending secret messages in an end-to-end scenario. To encrypt and/or authenticat a shared-key, such as symmetric encryption, Sodium uses the XSalsa20 algorithm to encrypt and HMAC-SHA512 for the authentication. -.. note:: CodeIgniter's ``SodiumHandler`` uses ``sodium_memzero`` in every encryption or decryption - session. After each session, the message (whether plaintext or ciphertext) and starter key are - wiped out from the buffers. You may need to provide again the key before starting a new session. - Message Length ============== 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 @@ + Date: Thu, 1 Jan 2026 19:18:03 +0100 Subject: [PATCH 2/2] fix phpstan --- tests/system/Debug/ToolbarTest.php | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/tests/system/Debug/ToolbarTest.php b/tests/system/Debug/ToolbarTest.php index 8d653cd263dc..16dceb943536 100644 --- a/tests/system/Debug/ToolbarTest.php +++ b/tests/system/Debug/ToolbarTest.php @@ -39,7 +39,7 @@ protected function setUp(): void parent::setUp(); Services::reset(); - is_cli(false); // @phpstan-ignore arguments.count + is_cli(false); $this->config = new ToolbarConfig(); @@ -55,7 +55,7 @@ protected function setUp(): void protected function tearDown(): void { // Restore is_cli state - is_cli(true); // @phpstan-ignore arguments.count + is_cli(true); parent::tearDown(); }