feat(encryption): Add previous keys fallback feature#9853
feat(encryption): Add previous keys fallback feature#9853patel-vansh wants to merge 23 commits intocodeigniter4:4.7from
Conversation
…decryption failed
app/Config/Encryption.php
Outdated
| * If you want to enable decryption using previous keys, set them here. | ||
| * See the user guide for more info. | ||
| */ | ||
| public array $previousKeys = []; |
There was a problem hiding this comment.
The encryption config can take in values from the .env file and having array properties can be hard to be populated. I suggest this takes a string of comma separated app keys instead.
There was a problem hiding this comment.
We can do that. So, this would be changed to comma separated string, right? So, we will have to do like, convert hex2bin or base64_decode on the fly when needed, or maybe, convert all previous keys and implode them as comma separated keys?
Which approach would be more better?
There was a problem hiding this comment.
I've currently set the second approach as for now (while initializing in the BaseConfig, do the parsing stuff and then implode as comma-separated string for future use), but we can use the other approach as well.
… fallback decryption
|
I don't know why this static analysis action is failing. The Thanks for help. |
michalsn
left a comment
There was a problem hiding this comment.
Nice start. Some thoughts: I think we should only fall back to the previous keys if the $params do not contain a key. If a key is provided in the $params, it suggests we are dealing with a custom encryption key, and the previous keys are meant to work only with the key from the config file, which would be overridden.
I would explore the option to wrap decrypt() methods content into a callback.
| $result = false; | ||
|
|
||
| try { | ||
| $result = $this->decryptWithKey($data, $this->key); | ||
| sodium_memzero($this->key); | ||
| } catch (EncryptionException $e) { | ||
| $exception = $e; | ||
| sodium_memzero($this->key); | ||
| } | ||
|
|
||
| if ($result === false && $this->previousKeys !== '') { | ||
| foreach (explode(',', $this->previousKeys) as $previousKey) { | ||
| try { | ||
| $result = $this->decryptWithKey($data, $previousKey); | ||
| if (isset($result)) { | ||
| return $result; | ||
| } | ||
| } catch (EncryptionException) { | ||
| // Try next key | ||
| } | ||
| } | ||
| } | ||
|
|
||
| if (isset($exception)) { | ||
| throw $exception; | ||
| } | ||
|
|
||
| return $result; |
There was a problem hiding this comment.
I find this hard to read in its current form. Wrapping the logic in a callback method might make it clearer and easier to maintain, especially since the existing method logic won't change.
There was a problem hiding this comment.
I tried to make it a more readable, please check now.
There was a problem hiding this comment.
I was thinking of adding this method to the BaseHandler:
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;
}
}and then using it like:
public function decrypt($data, #[SensitiveParameter] $params = null)
{
return $this->tryDecryptWithFallback($data, $params, function ($data, $params): string {
// original method content
});
}From my perspective, this is clearer, as the original methods remain largely unchanged and a single shared method handles both handlers.
There was a problem hiding this comment.
Yeah, adding it in BaseHandler.php was also my first thought, but I thought that anyone that has created their own encryption handler would have to implement this method after update.
So, will it be okay to introduce a new method in base handler?
There was a problem hiding this comment.
Yes, as long as it's universal and can be used with any handler.
There was a problem hiding this comment.
One thing I would like to note here is, in the OpenSSLHandler.php, openssl_decrypt method returns string|false. So, only relying on catching EncryptionException to go for fallback would not be best especially for OpenSSL Handler.
There was a problem hiding this comment.
I was thinking something like this for BaseHandler.php
protected function tryDecryptWithFallback($data, #[SensitiveParameter] $params, callable $decryptCallback)
{
$exception = null;
try {
$result = $decryptCallback($data, $params);
// Handle case where decryption returns false (like OpenSSL)
if ($result !== false) {
return $result;
}
} catch (EncryptionException $e) {
$exception = $e;
}
// Only try fallback keys if:
// 1. We have previous keys configured
// 2. No custom key was provided in params
if ($this->previousKeys === []) {
if ($exception !== null) {
throw $exception;
}
return false;
}
if (is_string($params) || (is_array($params) && isset($params['key']))) {
if ($exception !== null) {
throw $exception;
}
return false;
}
// Try each previous key
foreach ($this->previousKeys as $previousKey) {
try {
$previousParams = is_array($params)
? array_merge($params, ['key' => $previousKey])
: $previousKey;
$result = $decryptCallback($data, $previousParams);
// Return on success (not false)
if ($result !== false) {
return $result;
}
} catch (EncryptionException) {
// Continue to next key
continue;
}
}
// All keys failed - throw original exception or return false
if ($exception !== null) {
throw $exception;
}
return false;
}What are your thoughts on this?
There was a problem hiding this comment.
Good catch on openssl_decrypt() returning false. Given the existing pre-checks, this case should be extremely unlikely. I suggest updating OpenSSLHandler to throw EncryptionException::forAuthenticationFailed() when the result is false, which would allow us to simplify the BaseHandler logic.
There was a problem hiding this comment.
I've done that. Please check.
|
One note: Just committed it to get reviews on the working and logic of that function. I think we can solve this by introducing method inside |
| * | ||
| * @var list<string>|string | ||
| */ | ||
| protected array|string $previousKeys = ''; |
| /** | ||
| * Encryption key info. | ||
| * This setting is only used by OpenSSLHandler. | ||
| * | ||
| * Set to 'encryption' for CI3 Encryption compatibility. | ||
| */ | ||
| public string $encryptKeyInfo = ''; | ||
|
|
||
| /** | ||
| * Authentication key info. | ||
| * This setting is only used by OpenSSLHandler. | ||
| * | ||
| * Set to 'authentication' for CI3 Encryption compatibility. | ||
| */ | ||
| public string $authKeyInfo = ''; | ||
|
|
There was a problem hiding this comment.
Please relocate this code to its original location.
| // Allow key override | ||
| if ($params !== null) { | ||
| $this->key = is_array($params) && isset($params['key']) ? $params['key'] : $params; | ||
| } | ||
| $decryptParams = $params ?? $this->key; | ||
|
|
||
| if (empty($this->key)) { | ||
| if (empty($decryptParams)) { | ||
| throw EncryptionException::forNeedsStarterKey(); | ||
| } | ||
|
|
||
| // derive a secret key | ||
| $authKey = \hash_hkdf($this->digest, $this->key, 0, $this->authKeyInfo); | ||
| return $this->tryDecryptWithFallback($data, $decryptParams, function ($data, $params): string { | ||
| $key = is_array($params) && isset($params['key']) ? $params['key'] : $params; | ||
|
|
||
| $hmacLength = $this->rawData | ||
| ? $this->digestSize[$this->digest] | ||
| : $this->digestSize[$this->digest] * 2; | ||
| $authKey = \hash_hkdf($this->digest, $key, 0, $this->authKeyInfo); | ||
|
|
||
| $hmacKey = self::substr($data, 0, $hmacLength); | ||
| $data = self::substr($data, $hmacLength); | ||
| $hmacCalc = \hash_hmac($this->digest, $data, $authKey, $this->rawData); | ||
| $hmacLength = $this->rawData | ||
| ? $this->digestSize[$this->digest] | ||
| : $this->digestSize[$this->digest] * 2; | ||
|
|
||
| if (! hash_equals($hmacKey, $hmacCalc)) { | ||
| throw EncryptionException::forAuthenticationFailed(); | ||
| } | ||
| $hmacKey = self::substr($data, 0, $hmacLength); | ||
| $data = self::substr($data, $hmacLength); | ||
| $hmacCalc = \hash_hmac($this->digest, $data, $authKey, $this->rawData); | ||
|
|
||
| $data = $this->rawData ? $data : base64_decode($data, true); | ||
| if (! hash_equals($hmacKey, $hmacCalc)) { | ||
| throw EncryptionException::forAuthenticationFailed(); | ||
| } | ||
|
|
||
| if ($ivSize = \openssl_cipher_iv_length($this->cipher)) { | ||
| $iv = self::substr($data, 0, $ivSize); | ||
| $data = self::substr($data, $ivSize); | ||
| } else { | ||
| $iv = null; | ||
| } | ||
| $data = $this->rawData ? $data : base64_decode($data, true); | ||
|
|
||
| // derive a secret key | ||
| $encryptKey = \hash_hkdf($this->digest, $this->key, 0, $this->encryptKeyInfo); | ||
| 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, $key, 0, $this->encryptKeyInfo); | ||
|
|
||
| $decryptedData = \openssl_decrypt($data, $this->cipher, $encryptKey, OPENSSL_RAW_DATA, $iv); | ||
|
|
||
| if ($decryptedData === false) { | ||
| throw EncryptionException::forAuthenticationFailed(); | ||
| } | ||
|
|
||
| return \openssl_decrypt($data, $this->cipher, $encryptKey, OPENSSL_RAW_DATA, $iv); | ||
| return $decryptedData; | ||
| }); |
There was a problem hiding this comment.
There is a problem - when you do:
$decryptParams = $params ?? $this->key;You're always passing a key value to tryDecryptWithFallback. However, if you look at BaseHandler, there are lines:
if (is_string($params) || (is_array($params) && isset($params['key']))) {
throw $e;
}This check means: if an explicit key was provided in $params, don't try previousKeys.
I would try doing it this way:
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($key)) {
throw EncryptionException::forNeedsStarterKey();
}
// 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;
$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();
}
$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;
}
// 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 $result;
});And a similar approach with the SodiumHandler. At this point, it would be nice to have some tests for this.
|
After reviewing the current implementation, I think there are handler-level issues that block a reliable implementation of this feature. I will put together a separate PR. Thanks for your work on this - and of course, you will be credited as a co-author. |
|
Ok. Thank you @michalsn for your help. Looking forward to have this feature in 4.7. |
|
Resolved via #9870 |
|
Thank you @michalsn for this feature. |
Description
This PR adds a new feature to the encryption service. Currently encryption service only supports single key decryption. This feature adds a feature to enable fallback keys which can be used if data can't be decrypted by the main key.
For more detailed use cases and motivation, please take a look at this forum post.
For equivalent Laravel implementation, please look at this page.
Note:
This PR is not complete yet. I need help in testing as well as user guide update according to this new change. I've done basic logic implementation and hence I am creating this PR to let others have a look at the change and suggest some changes. Testing and User guide updation is still remaining.
Checklist: