Skip to content
Open
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 @@ -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>|string
*/
public array|string $previousKeys = '';
}
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
49 changes: 49 additions & 0 deletions system/Encryption/Handlers/BaseHandler.php
Original file line number Diff line number Diff line change
Expand Up @@ -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<string>
*/
protected array $previousKeys = [];

/**
* Constructor
*/
Expand Down Expand Up @@ -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, bool|int|string>|string|null $params Overridden parameters, specifically the key
* @param callable(string, array<string, bool|int|string>|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
*
Expand Down
76 changes: 41 additions & 35 deletions system/Encryption/Handlers/OpenSSLHandler.php
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand All @@ -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);

Expand All @@ -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;
});
}
}
80 changes: 48 additions & 32 deletions system/Encryption/Handlers/SodiumHandler.php
Original file line number Diff line number Diff line change
Expand Up @@ -43,28 +43,34 @@ 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();
}

// create a nonce for this operation
$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;
}
Expand All @@ -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;
});
}

/**
Expand All @@ -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)
{
Expand Down
Loading
Loading