Skip to content

Commit 90f8577

Browse files
michalsnpatel-vanshpaulbalandan
committed
feat: encryption key rotation
Co-authored-by: patel-vansh <developer.patelvansh@gmail.com> Co-authored-by: John Paul E. Balandan, CPA <paulbalandan@gmail.com>
1 parent a46eeee commit 90f8577

File tree

9 files changed

+534
-8
lines changed

9 files changed

+534
-8
lines changed

app/Config/Encryption.php

Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -23,6 +23,23 @@ class Encryption extends BaseConfig
2323
*/
2424
public string $key = '';
2525

26+
/**
27+
* --------------------------------------------------------------------------
28+
* Previous Encryption Keys
29+
* --------------------------------------------------------------------------
30+
*
31+
* When rotating encryption keys, add old keys here to maintain ability
32+
* to decrypt data encrypted with previous keys. Encryption always uses
33+
* the current $key. Decryption tries current key first, then falls back
34+
* to previous keys if decryption fails.
35+
*
36+
* In .env file, use comma-separated string:
37+
* encryption.previousKeys = hex2bin:9be8c64fcea509867...,hex2bin:3f5a1d8e9c2b7a4f6...
38+
*
39+
* @var list<string>|string
40+
*/
41+
public array|string $previousKeys = '';
42+
2643
/**
2744
* --------------------------------------------------------------------------
2845
* Encryption Driver to Use

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/Encryption.php

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -138,6 +138,10 @@ public function initialize(?EncryptionConfig $config = null)
138138
$handlerName = 'CodeIgniter\\Encryption\\Handlers\\' . $this->driver . 'Handler';
139139
$this->encrypter = new $handlerName($config);
140140

141+
if ($config->previousKeys !== []) {
142+
$this->encrypter = new KeyRotationDecorator($this->encrypter, $config->previousKeys);
143+
}
144+
141145
return $this->encrypter;
142146
}
143147

system/Encryption/Handlers/OpenSSLHandler.php

Lines changed: 7 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -154,6 +154,12 @@ public function decrypt($data, #[SensitiveParameter] $params = null)
154154
// derive a secret key
155155
$encryptKey = \hash_hkdf($this->digest, $key, 0, $this->encryptKeyInfo);
156156

157-
return \openssl_decrypt($data, $this->cipher, $encryptKey, OPENSSL_RAW_DATA, $iv);
157+
$result = \openssl_decrypt($data, $this->cipher, $encryptKey, OPENSSL_RAW_DATA, $iv);
158+
159+
if ($result === false) {
160+
throw EncryptionException::forAuthenticationFailed();
161+
}
162+
163+
return $result;
158164
}
159165
}
Lines changed: 111 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,111 @@
1+
<?php
2+
3+
declare(strict_types=1);
4+
5+
/**
6+
* This file is part of CodeIgniter 4 framework.
7+
*
8+
* (c) CodeIgniter Foundation <admin@codeigniter.com>
9+
*
10+
* For the full copyright and license information, please view
11+
* the LICENSE file that was distributed with this source code.
12+
*/
13+
14+
namespace CodeIgniter\Encryption;
15+
16+
use CodeIgniter\Encryption\Exceptions\EncryptionException;
17+
use SensitiveParameter;
18+
19+
/**
20+
* Key Rotation Decorator
21+
*
22+
* Wraps any EncrypterInterface implementation to provide automatic
23+
* fallback to previous encryption keys during decryption. This enables
24+
* seamless key rotation without requiring re-encryption of existing data.
25+
*/
26+
class KeyRotationDecorator implements EncrypterInterface
27+
{
28+
/**
29+
* @param EncrypterInterface $innerHandler The wrapped encryption handler
30+
* @param list<string> $previousKeys Array of previous encryption keys
31+
*/
32+
public function __construct(
33+
private readonly EncrypterInterface $innerHandler,
34+
private readonly array $previousKeys,
35+
) {
36+
}
37+
38+
/**
39+
* {@inheritDoc}
40+
*
41+
* Encryption always uses the inner handler's current key.
42+
*/
43+
public function encrypt(#[SensitiveParameter] $data, #[SensitiveParameter] $params = null)
44+
{
45+
return $this->innerHandler->encrypt($data, $params);
46+
}
47+
48+
/**
49+
* {@inheritDoc}
50+
*
51+
* Attempts decryption with current key first. If that fails and no
52+
* explicit key was provided in $params, tries each previous key.
53+
*
54+
* @throws EncryptionException
55+
*/
56+
public function decrypt($data, #[SensitiveParameter] $params = null)
57+
{
58+
try {
59+
return $this->innerHandler->decrypt($data, $params);
60+
} catch (EncryptionException $e) {
61+
// Don't try previous keys if an explicit key was provided
62+
if (is_string($params) || (is_array($params) && isset($params['key']))) {
63+
throw $e;
64+
}
65+
66+
if ($this->previousKeys === []) {
67+
throw $e;
68+
}
69+
70+
foreach ($this->previousKeys as $previousKey) {
71+
try {
72+
$previousParams = is_array($params)
73+
? array_merge($params, ['key' => $previousKey])
74+
: $previousKey;
75+
76+
return $this->innerHandler->decrypt($data, $previousParams);
77+
} catch (EncryptionException) {
78+
continue;
79+
}
80+
}
81+
82+
throw $e;
83+
}
84+
}
85+
86+
/**
87+
* Delegate property access to the inner handler.
88+
*
89+
* @return array|bool|int|string|null
90+
*/
91+
public function __get(string $key)
92+
{
93+
if (method_exists($this->innerHandler, '__get')) {
94+
return $this->innerHandler->__get($key);
95+
}
96+
97+
return null;
98+
}
99+
100+
/**
101+
* Delegate property existence check to inner handler.
102+
*/
103+
public function __isset(string $key): bool
104+
{
105+
if (method_exists($this->innerHandler, '__isset')) {
106+
return $this->innerHandler->__isset($key);
107+
}
108+
109+
return false;
110+
}
111+
}

0 commit comments

Comments
 (0)