Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
17 changes: 17 additions & 0 deletions app/Config/Encryption.php
Original file line number Diff line number Diff line change
Expand Up @@ -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>|string
*/
public array|string $previousKeys = '';

/**
* --------------------------------------------------------------------------
* Encryption Driver to Use
Expand Down
35 changes: 28 additions & 7 deletions system/Config/BaseConfig.php
Original file line number Diff line number Diff line change
Expand Up @@ -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
*
Expand Down
4 changes: 4 additions & 0 deletions system/Encryption/Encryption.php
Original file line number Diff line number Diff line change
Expand Up @@ -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;
}

Expand Down
8 changes: 7 additions & 1 deletion system/Encryption/Handlers/OpenSSLHandler.php
Original file line number Diff line number Diff line change
Expand Up @@ -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;
}
}
111 changes: 111 additions & 0 deletions system/Encryption/KeyRotationDecorator.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,111 @@
<?php

declare(strict_types=1);

/**
* This file is part of CodeIgniter 4 framework.
*
* (c) CodeIgniter Foundation <admin@codeigniter.com>
*
* 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<string> $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;
}
}
Loading
Loading