diff --git a/app/Config/Encryption.php b/app/Config/Encryption.php index 28344134aa31..d752ce5975e4 100644 --- a/app/Config/Encryption.php +++ b/app/Config/Encryption.php @@ -23,6 +23,17 @@ class Encryption extends BaseConfig */ public string $key = ''; + /** + * -------------------------------------------------------------------------- + * Previous Encryption Keys + * -------------------------------------------------------------------------- + * If you want to enable decryption using previous keys, set them here. + * See the user guide for more info. + * + * @var list|string + */ + public array|string $previousKeys = ''; + /** * -------------------------------------------------------------------------- * Encryption Driver to Use diff --git a/env b/env index f359ec20bff2..ce4f5586bd69 100644 --- a/env +++ b/env @@ -55,6 +55,9 @@ # encryption.key = +# Previous keys fallback; comma-separated list +# encryption.previousKeys = + #-------------------------------------------------------------------- # SESSION #-------------------------------------------------------------------- diff --git a/system/Config/BaseConfig.php b/system/Config/BaseConfig.php index ce6594d45d36..46e401f7b1a9 100644 --- a/system/Config/BaseConfig.php +++ b/system/Config/BaseConfig.php @@ -130,18 +130,35 @@ 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; } } } } + 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..be383657d4a4 100644 --- a/system/Encryption/Encryption.php +++ b/system/Encryption/Encryption.php @@ -53,6 +53,13 @@ class Encryption */ protected $key; + /** + * Array or Comma-separated list of previous keys for fallback decryption. + * + * @var list|string + */ + protected array|string $previousKeys = ''; + /** * The derived HMAC key * @@ -91,9 +98,10 @@ public function __construct(?EncryptionConfig $config = null) { $config ??= new EncryptionConfig(); - $this->key = $config->key; - $this->driver = $config->driver; - $this->digest = $config->digest ?? 'SHA512'; + $this->key = $config->key; + $this->previousKeys = $config->previousKeys; + $this->driver = $config->driver; + $this->digest = $config->digest ?? 'SHA512'; $this->handlers = [ 'OpenSSL' => extension_loaded('openssl'), @@ -116,9 +124,10 @@ public function __construct(?EncryptionConfig $config = null) public function initialize(?EncryptionConfig $config = null) { if ($config instanceof EncryptionConfig) { - $this->key = $config->key; - $this->driver = $config->driver; - $this->digest = $config->digest ?? 'SHA512'; + $this->key = $config->key; + $this->previousKeys = $config->previousKeys ?? ''; + $this->driver = $config->driver; + $this->digest = $config->digest ?? 'SHA512'; } if (empty($this->driver)) { diff --git a/system/Encryption/Handlers/OpenSSLHandler.php b/system/Encryption/Handlers/OpenSSLHandler.php index 9dca5b90304c..cb486709bc6c 100644 --- a/system/Encryption/Handlers/OpenSSLHandler.php +++ b/system/Encryption/Handlers/OpenSSLHandler.php @@ -56,6 +56,13 @@ class OpenSSLHandler extends BaseHandler */ protected $key = ''; + /** + * List of previous keys for fallback decryption. + * + * @var list|string + */ + protected array|string $previousKeys = ''; + /** * Whether the cipher-text should be raw. If set to false, then it will be base64 encoded. */ @@ -127,8 +134,56 @@ public function decrypt($data, #[SensitiveParameter] $params = null) throw EncryptionException::forNeedsStarterKey(); } + // Only use fallback keys if no custom key was provided in params + $useFallback = ! isset($params['key']); + + $attemptDecrypt = function ($key) use ($data): array { + try { + $result = $this->decryptWithKey($data, $key); + + return ['success' => true, 'data' => $result]; + } catch (EncryptionException $e) { + return ['success' => false, 'exception' => $e]; + } + }; + + $result = $attemptDecrypt($this->key); + + if ($result['success']) { + return $result['data']; + } + + $originalException = $result['exception']; + + // If primary key failed and fallback is allowed, try previous keys + if ($useFallback && ! in_array($this->previousKeys, ['', '0', []], true)) { + foreach ($this->previousKeys as $previousKey) { + $fallbackResult = $attemptDecrypt($previousKey); + + if ($fallbackResult['success']) { + return $fallbackResult['data']; + } + } + } + + // All attempts failed - throw the original exception + throw $originalException; + } + + /** + * Decrypt the data with the provided key + * + * @param string $data + * @param string $key + * + * @return false|string + * + * @throws EncryptionException + */ + protected function decryptWithKey($data, #[SensitiveParameter] $key) + { // derive a secret key - $authKey = \hash_hkdf($this->digest, $this->key, 0, $this->authKeyInfo); + $authKey = \hash_hkdf($this->digest, $key, 0, $this->authKeyInfo); $hmacLength = $this->rawData ? $this->digestSize[$this->digest] @@ -152,7 +207,7 @@ public function decrypt($data, #[SensitiveParameter] $params = null) } // derive a secret key - $encryptKey = \hash_hkdf($this->digest, $this->key, 0, $this->encryptKeyInfo); + $encryptKey = \hash_hkdf($this->digest, $key, 0, $this->encryptKeyInfo); return \openssl_decrypt($data, $this->cipher, $encryptKey, OPENSSL_RAW_DATA, $iv); } diff --git a/system/Encryption/Handlers/SodiumHandler.php b/system/Encryption/Handlers/SodiumHandler.php index 45f9ac2fa383..5b71e6750234 100644 --- a/system/Encryption/Handlers/SodiumHandler.php +++ b/system/Encryption/Handlers/SodiumHandler.php @@ -31,6 +31,13 @@ class SodiumHandler extends BaseHandler */ protected $key = ''; + /** + * List of previous keys for fallback decryption. + * + * @var list|string + */ + protected array|string $previousKeys = ''; + /** * Block size for padding message. * @@ -80,6 +87,56 @@ public function decrypt($data, #[SensitiveParameter] $params = null) throw EncryptionException::forNeedsStarterKey(); } + // Only use fallback keys if no custom key was provided in params + $useFallback = ! isset($params['key']); + + $attemptDecrypt = function ($key) use ($data): array { + try { + $result = $this->decryptWithKey($data, $key); + sodium_memzero($key); + + return ['success' => true, 'data' => $result]; + } catch (EncryptionException $e) { + sodium_memzero($key); + + return ['success' => false, 'exception' => $e]; + } + }; + + $result = $attemptDecrypt($this->key); + + if ($result['success']) { + return $result['data']; + } + + $originalException = $result['exception']; + + // If primary key failed and fallback is allowed, try previous keys + if ($useFallback && ! in_array($this->previousKeys, ['', '0', []], true)) { + foreach ($this->previousKeys as $previousKey) { + $fallbackResult = $attemptDecrypt($previousKey); + + if ($fallbackResult['success']) { + return $fallbackResult['data']; + } + } + } + + throw $originalException; + } + + /** + * Decrypt the data with the provided key + * + * @param string $data + * @param string $key + * + * @return string + * + * @throws EncryptionException + */ + protected function decryptWithKey($data, #[SensitiveParameter] $key) + { if (mb_strlen($data, '8bit') < (SODIUM_CRYPTO_SECRETBOX_NONCEBYTES + SODIUM_CRYPTO_SECRETBOX_MACBYTES)) { // message was truncated throw EncryptionException::forAuthenticationFailed(); @@ -90,7 +147,7 @@ public function decrypt($data, #[SensitiveParameter] $params = null) $ciphertext = self::substr($data, SODIUM_CRYPTO_SECRETBOX_NONCEBYTES); // decrypt data - $data = sodium_crypto_secretbox_open($ciphertext, $nonce, $this->key); + $data = sodium_crypto_secretbox_open($ciphertext, $nonce, $key); if ($data === false) { // message was tampered in transit @@ -106,7 +163,6 @@ public function decrypt($data, #[SensitiveParameter] $params = null) // cleanup buffers sodium_memzero($ciphertext); - sodium_memzero($this->key); return $data; } @@ -120,7 +176,7 @@ public function decrypt($data, #[SensitiveParameter] $params = null) * * @throws EncryptionException If key is empty */ - protected function parseParams($params) + protected function parseParams(#[SensitiveParameter] $params) { if ($params === null) { return;