Skip to content

Commit 14aa07b

Browse files
michalsnpatel-vansh
andcommitted
feat: encryption previousKeys support
Co-authored-by: patel-vansh <developer.patelvansh@gmail.com>
1 parent ff20d81 commit 14aa07b

File tree

12 files changed

+550
-86
lines changed

12 files changed

+550
-86
lines changed

app/Config/Encryption.php

Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -89,4 +89,21 @@ class Encryption extends BaseConfig
8989
* by CI3 Encryption default configuration.
9090
*/
9191
public string $cipher = 'AES-256-CTR';
92+
93+
/**
94+
* --------------------------------------------------------------------------
95+
* Previous Encryption Keys
96+
* --------------------------------------------------------------------------
97+
*
98+
* When rotating encryption keys, add old keys here to maintain ability
99+
* to decrypt data encrypted with previous keys. Encryption always uses
100+
* the current $key. Decryption tries current key first, then falls back
101+
* to previous keys if decryption fails.
102+
*
103+
* In .env file, use comma-separated string:
104+
* encryption.previousKeys = hex2bin:9be8c64fcea509867...,hex2bin:3f5a1d8e9c2b7a4f6...
105+
*
106+
* @var list<string>|string
107+
*/
108+
public array|string $previousKeys = '';
92109
}

system/Config/BaseConfig.php

Lines changed: 28 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -130,18 +130,39 @@ public function __construct()
130130
foreach ($properties as $property) {
131131
$this->initEnvValue($this->{$property}, $property, $prefix, $shortPrefix);
132132

133-
if ($this instanceof Encryption && $property === 'key') {
134-
if (str_starts_with($this->{$property}, 'hex2bin:')) {
135-
// Handle hex2bin prefix
136-
$this->{$property} = hex2bin(substr($this->{$property}, 8));
137-
} elseif (str_starts_with($this->{$property}, 'base64:')) {
138-
// Handle base64 prefix
139-
$this->{$property} = base64_decode(substr($this->{$property}, 7), true);
133+
if ($this instanceof Encryption) {
134+
if ($property === 'key') {
135+
$this->{$property} = $this->parseEncryptionKey($this->{$property});
136+
} elseif ($property === 'previousKeys') {
137+
$keysArray = is_string($this->{$property}) ? array_map(trim(...), explode(',', $this->{$property})) : $this->{$property};
138+
$parsedKeys = [];
139+
140+
foreach ($keysArray as $key) {
141+
$parsedKeys[] = $this->parseEncryptionKey($key);
142+
}
143+
144+
$this->{$property} = $parsedKeys;
140145
}
141146
}
142147
}
143148
}
144149

150+
/**
151+
* Parse encryption key with hex2bin: or base64: prefix
152+
*/
153+
protected function parseEncryptionKey(string $key): string
154+
{
155+
if (str_starts_with($key, 'hex2bin:')) {
156+
return hex2bin(substr($key, 8));
157+
}
158+
159+
if (str_starts_with($key, 'base64:')) {
160+
return base64_decode(substr($key, 7), true);
161+
}
162+
163+
return $key;
164+
}
165+
145166
/**
146167
* Initialization an environment-specific configuration setting
147168
*

system/Encryption/Handlers/BaseHandler.php

Lines changed: 49 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -14,13 +14,22 @@
1414
namespace CodeIgniter\Encryption\Handlers;
1515

1616
use CodeIgniter\Encryption\EncrypterInterface;
17+
use CodeIgniter\Encryption\Exceptions\EncryptionException;
1718
use Config\Encryption;
19+
use SensitiveParameter;
1820

1921
/**
2022
* Base class for encryption handling
2123
*/
2224
abstract class BaseHandler implements EncrypterInterface
2325
{
26+
/**
27+
* Previous encryption keys for decryption fallback
28+
*
29+
* @var list<string>
30+
*/
31+
protected array $previousKeys = [];
32+
2433
/**
2534
* Constructor
2635
*/
@@ -50,6 +59,46 @@ protected static function substr($str, $start, $length = null)
5059
return mb_substr($str, $start, $length, '8bit');
5160
}
5261

62+
/**
63+
* Try to decrypt data with fallback to previous keys
64+
*
65+
* @param string $data Data to decrypt
66+
* @param array<string, bool|int|string>|string|null $params Overridden parameters, specifically the key
67+
* @param callable(string, array<string, bool|int|string>|string|null): string $decryptCallback Callback that performs the actual decryption
68+
*
69+
* @return string
70+
*
71+
* @throws EncryptionException
72+
*/
73+
protected function tryDecryptWithFallback($data, #[SensitiveParameter] $params, callable $decryptCallback)
74+
{
75+
try {
76+
return $decryptCallback($data, $params);
77+
} catch (EncryptionException $e) {
78+
if ($this->previousKeys === []) {
79+
throw $e;
80+
}
81+
82+
if (is_string($params) || (is_array($params) && isset($params['key']))) {
83+
throw $e;
84+
}
85+
86+
foreach ($this->previousKeys as $previousKey) {
87+
try {
88+
$previousParams = is_array($params)
89+
? array_merge($params, ['key' => $previousKey])
90+
: $previousKey;
91+
92+
return $decryptCallback($data, $previousParams);
93+
} catch (EncryptionException) {
94+
continue;
95+
}
96+
}
97+
98+
throw $e;
99+
}
100+
}
101+
53102
/**
54103
* __get() magic, providing readonly access to some of our properties
55104
*

system/Encryption/Handlers/OpenSSLHandler.php

Lines changed: 41 additions & 35 deletions
Original file line numberDiff line numberDiff line change
@@ -82,17 +82,16 @@ class OpenSSLHandler extends BaseHandler
8282
*/
8383
public function encrypt(#[SensitiveParameter] $data, #[SensitiveParameter] $params = null)
8484
{
85-
// Allow key override
86-
if ($params !== null) {
87-
$this->key = is_array($params) && isset($params['key']) ? $params['key'] : $params;
88-
}
85+
$key = $params !== null
86+
? (is_array($params) && isset($params['key']) ? $params['key'] : $params)
87+
: $this->key;
8988

90-
if (empty($this->key)) {
89+
if (empty($key)) {
9190
throw EncryptionException::forNeedsStarterKey();
9291
}
9392

9493
// derive a secret key
95-
$encryptKey = \hash_hkdf($this->digest, $this->key, 0, $this->encryptKeyInfo);
94+
$encryptKey = \hash_hkdf($this->digest, $key, 0, $this->encryptKeyInfo);
9695

9796
// basic encryption
9897
$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
106105
$result = $this->rawData ? $iv . $data : base64_encode($iv . $data);
107106

108107
// derive a secret key
109-
$authKey = \hash_hkdf($this->digest, $this->key, 0, $this->authKeyInfo);
108+
$authKey = \hash_hkdf($this->digest, $key, 0, $this->authKeyInfo);
110109

111110
$hmacKey = \hash_hmac($this->digest, $result, $authKey, $this->rawData);
112111

@@ -118,42 +117,49 @@ public function encrypt(#[SensitiveParameter] $data, #[SensitiveParameter] $para
118117
*/
119118
public function decrypt($data, #[SensitiveParameter] $params = null)
120119
{
121-
// Allow key override
122-
if ($params !== null) {
123-
$this->key = is_array($params) && isset($params['key']) ? $params['key'] : $params;
124-
}
120+
return $this->tryDecryptWithFallback($data, $params, function ($data, $params): string {
121+
$key = $params !== null
122+
? (is_array($params) && isset($params['key']) ? $params['key'] : $params)
123+
: $this->key;
125124

126-
if (empty($this->key)) {
127-
throw EncryptionException::forNeedsStarterKey();
128-
}
125+
if (empty($key)) {
126+
throw EncryptionException::forNeedsStarterKey();
127+
}
129128

130-
// derive a secret key
131-
$authKey = \hash_hkdf($this->digest, $this->key, 0, $this->authKeyInfo);
129+
// derive a secret key
130+
$authKey = \hash_hkdf($this->digest, $key, 0, $this->authKeyInfo);
132131

133-
$hmacLength = $this->rawData
134-
? $this->digestSize[$this->digest]
135-
: $this->digestSize[$this->digest] * 2;
132+
$hmacLength = $this->rawData
133+
? $this->digestSize[$this->digest]
134+
: $this->digestSize[$this->digest] * 2;
136135

137-
$hmacKey = self::substr($data, 0, $hmacLength);
138-
$data = self::substr($data, $hmacLength);
139-
$hmacCalc = \hash_hmac($this->digest, $data, $authKey, $this->rawData);
136+
$hmacKey = self::substr($data, 0, $hmacLength);
137+
$data = self::substr($data, $hmacLength);
138+
$hmacCalc = \hash_hmac($this->digest, $data, $authKey, $this->rawData);
140139

141-
if (! hash_equals($hmacKey, $hmacCalc)) {
142-
throw EncryptionException::forAuthenticationFailed();
143-
}
140+
if (! hash_equals($hmacKey, $hmacCalc)) {
141+
throw EncryptionException::forAuthenticationFailed();
142+
}
144143

145-
$data = $this->rawData ? $data : base64_decode($data, true);
144+
$data = $this->rawData ? $data : base64_decode($data, true);
146145

147-
if ($ivSize = \openssl_cipher_iv_length($this->cipher)) {
148-
$iv = self::substr($data, 0, $ivSize);
149-
$data = self::substr($data, $ivSize);
150-
} else {
151-
$iv = null;
152-
}
146+
if ($ivSize = \openssl_cipher_iv_length($this->cipher)) {
147+
$iv = self::substr($data, 0, $ivSize);
148+
$data = self::substr($data, $ivSize);
149+
} else {
150+
$iv = null;
151+
}
153152

154-
// derive a secret key
155-
$encryptKey = \hash_hkdf($this->digest, $this->key, 0, $this->encryptKeyInfo);
153+
// derive a secret key
154+
$encryptKey = \hash_hkdf($this->digest, $key, 0, $this->encryptKeyInfo);
155+
156+
$result = \openssl_decrypt($data, $this->cipher, $encryptKey, OPENSSL_RAW_DATA, $iv);
157+
158+
if ($result === false) {
159+
throw EncryptionException::forAuthenticationFailed();
160+
}
156161

157-
return \openssl_decrypt($data, $this->cipher, $encryptKey, OPENSSL_RAW_DATA, $iv);
162+
return $result;
163+
});
158164
}
159165
}

system/Encryption/Handlers/SodiumHandler.php

Lines changed: 48 additions & 32 deletions
Original file line numberDiff line numberDiff line change
@@ -43,28 +43,34 @@ class SodiumHandler extends BaseHandler
4343
*/
4444
public function encrypt(#[SensitiveParameter] $data, #[SensitiveParameter] $params = null)
4545
{
46-
$this->parseParams($params);
46+
$key = $params !== null
47+
? (is_array($params) && isset($params['key']) ? $params['key'] : $params)
48+
: $this->key;
4749

48-
if (empty($this->key)) {
50+
$blockSize = is_array($params) && isset($params['blockSize'])
51+
? $params['blockSize']
52+
: $this->blockSize;
53+
54+
if (empty($key)) {
4955
throw EncryptionException::forNeedsStarterKey();
5056
}
5157

5258
// create a nonce for this operation
5359
$nonce = random_bytes(SODIUM_CRYPTO_SECRETBOX_NONCEBYTES); // 24 bytes
5460

5561
// add padding before we encrypt the data
56-
if ($this->blockSize <= 0) {
62+
if ($blockSize <= 0) {
5763
throw EncryptionException::forEncryptionFailed();
5864
}
5965

60-
$data = sodium_pad($data, $this->blockSize);
66+
$data = sodium_pad($data, $blockSize);
6167

6268
// encrypt message and combine with nonce
63-
$ciphertext = $nonce . sodium_crypto_secretbox($data, $nonce, $this->key);
69+
$ciphertext = $nonce . sodium_crypto_secretbox($data, $nonce, $key);
6470

6571
// cleanup buffers
6672
sodium_memzero($data);
67-
sodium_memzero($this->key);
73+
sodium_memzero($key);
6874

6975
return $ciphertext;
7076
}
@@ -74,41 +80,49 @@ public function encrypt(#[SensitiveParameter] $data, #[SensitiveParameter] $para
7480
*/
7581
public function decrypt($data, #[SensitiveParameter] $params = null)
7682
{
77-
$this->parseParams($params);
83+
return $this->tryDecryptWithFallback($data, $params, function ($data, $params): string {
84+
$key = $params !== null
85+
? (is_array($params) && isset($params['key']) ? $params['key'] : $params)
86+
: $this->key;
7887

79-
if (empty($this->key)) {
80-
throw EncryptionException::forNeedsStarterKey();
81-
}
88+
$blockSize = is_array($params) && isset($params['blockSize'])
89+
? $params['blockSize']
90+
: $this->blockSize;
8291

83-
if (mb_strlen($data, '8bit') < (SODIUM_CRYPTO_SECRETBOX_NONCEBYTES + SODIUM_CRYPTO_SECRETBOX_MACBYTES)) {
84-
// message was truncated
85-
throw EncryptionException::forAuthenticationFailed();
86-
}
92+
if (empty($key)) {
93+
throw EncryptionException::forNeedsStarterKey();
94+
}
8795

88-
// Extract info from encrypted data
89-
$nonce = self::substr($data, 0, SODIUM_CRYPTO_SECRETBOX_NONCEBYTES);
90-
$ciphertext = self::substr($data, SODIUM_CRYPTO_SECRETBOX_NONCEBYTES);
96+
if (mb_strlen($data, '8bit') < (SODIUM_CRYPTO_SECRETBOX_NONCEBYTES + SODIUM_CRYPTO_SECRETBOX_MACBYTES)) {
97+
// message was truncated
98+
throw EncryptionException::forAuthenticationFailed();
99+
}
91100

92-
// decrypt data
93-
$data = sodium_crypto_secretbox_open($ciphertext, $nonce, $this->key);
101+
// Extract info from encrypted data
102+
$nonce = self::substr($data, 0, SODIUM_CRYPTO_SECRETBOX_NONCEBYTES);
103+
$ciphertext = self::substr($data, SODIUM_CRYPTO_SECRETBOX_NONCEBYTES);
94104

95-
if ($data === false) {
96-
// message was tampered in transit
97-
throw EncryptionException::forAuthenticationFailed(); // @codeCoverageIgnore
98-
}
105+
// decrypt data
106+
$data = sodium_crypto_secretbox_open($ciphertext, $nonce, $key);
99107

100-
// remove extra padding during encryption
101-
if ($this->blockSize <= 0) {
102-
throw EncryptionException::forAuthenticationFailed();
103-
}
108+
if ($data === false) {
109+
// message was tampered in transit
110+
throw EncryptionException::forAuthenticationFailed(); // @codeCoverageIgnore
111+
}
112+
113+
// remove extra padding during encryption
114+
if ($blockSize <= 0) {
115+
throw EncryptionException::forAuthenticationFailed();
116+
}
104117

105-
$data = sodium_unpad($data, $this->blockSize);
118+
$data = sodium_unpad($data, $blockSize);
106119

107-
// cleanup buffers
108-
sodium_memzero($ciphertext);
109-
sodium_memzero($this->key);
120+
// cleanup buffers
121+
sodium_memzero($ciphertext);
122+
sodium_memzero($key);
110123

111-
return $data;
124+
return $data;
125+
});
112126
}
113127

114128
/**
@@ -119,6 +133,8 @@ public function decrypt($data, #[SensitiveParameter] $params = null)
119133
* @return void
120134
*
121135
* @throws EncryptionException If key is empty
136+
*
137+
* @deprecated 4.7.0 No longer used.
122138
*/
123139
protected function parseParams($params)
124140
{

0 commit comments

Comments
 (0)