Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
17 commits
Select commit Hold shift + click to select a range
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
11 changes: 11 additions & 0 deletions app/Config/Encryption.php
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,17 @@ class Encryption extends BaseConfig
*/
public string $key = '';

/**
* --------------------------------------------------------------------------
* Previous Encryption Keys
* --------------------------------------------------------------------------
* If you want to enable decryption using previous keys, set them here.
* See the user guide for more info.
*
* @var list<string>|string
*/
public array|string $previousKeys = '';

/**
* --------------------------------------------------------------------------
* Encryption Driver to Use
Expand Down
3 changes: 3 additions & 0 deletions env
Original file line number Diff line number Diff line change
Expand Up @@ -55,6 +55,9 @@

# encryption.key =

# Previous keys fallback; comma-separated list
# encryption.previousKeys =

#--------------------------------------------------------------------
# SESSION
#--------------------------------------------------------------------
Expand Down
31 changes: 24 additions & 7 deletions system/Config/BaseConfig.php
Original file line number Diff line number Diff line change
Expand Up @@ -130,18 +130,35 @@ 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;
}
}
}
}

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
21 changes: 15 additions & 6 deletions system/Encryption/Encryption.php
Original file line number Diff line number Diff line change
Expand Up @@ -53,6 +53,13 @@ class Encryption
*/
protected $key;

/**
* Array or Comma-separated list of previous keys for fallback decryption.
*
* @var list<string>|string
*/
protected array|string $previousKeys = '';

/**
* The derived HMAC key
*
Expand Down Expand Up @@ -91,9 +98,10 @@ public function __construct(?EncryptionConfig $config = null)
{
$config ??= new EncryptionConfig();

$this->key = $config->key;
$this->driver = $config->driver;
$this->digest = $config->digest ?? 'SHA512';
$this->key = $config->key;
$this->previousKeys = $config->previousKeys;
$this->driver = $config->driver;
$this->digest = $config->digest ?? 'SHA512';

$this->handlers = [
'OpenSSL' => extension_loaded('openssl'),
Expand All @@ -116,9 +124,10 @@ public function __construct(?EncryptionConfig $config = null)
public function initialize(?EncryptionConfig $config = null)
{
if ($config instanceof EncryptionConfig) {
$this->key = $config->key;
$this->driver = $config->driver;
$this->digest = $config->digest ?? 'SHA512';
$this->key = $config->key;
$this->previousKeys = $config->previousKeys ?? '';
$this->driver = $config->driver;
$this->digest = $config->digest ?? 'SHA512';
}

if (empty($this->driver)) {
Expand Down
59 changes: 57 additions & 2 deletions system/Encryption/Handlers/OpenSSLHandler.php
Original file line number Diff line number Diff line change
Expand Up @@ -56,6 +56,13 @@ class OpenSSLHandler extends BaseHandler
*/
protected $key = '';

/**
* List of previous keys for fallback decryption.
*
* @var list<string>|string
*/
protected array|string $previousKeys = '';

/**
* Whether the cipher-text should be raw. If set to false, then it will be base64 encoded.
*/
Expand Down Expand Up @@ -127,8 +134,56 @@ public function decrypt($data, #[SensitiveParameter] $params = null)
throw EncryptionException::forNeedsStarterKey();
}

// Only use fallback keys if no custom key was provided in params
$useFallback = ! isset($params['key']);
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

A string value (is_string($params)) should also be treated as a key.


$attemptDecrypt = function ($key) use ($data): array {
try {
$result = $this->decryptWithKey($data, $key);

return ['success' => true, 'data' => $result];
} catch (EncryptionException $e) {
return ['success' => false, 'exception' => $e];
}
};

$result = $attemptDecrypt($this->key);

if ($result['success']) {
return $result['data'];
}

$originalException = $result['exception'];

// If primary key failed and fallback is allowed, try previous keys
if ($useFallback && ! in_array($this->previousKeys, ['', '0', []], true)) {
foreach ($this->previousKeys as $previousKey) {
$fallbackResult = $attemptDecrypt($previousKey);

if ($fallbackResult['success']) {
return $fallbackResult['data'];
}
}
}

// All attempts failed - throw the original exception
throw $originalException;
}

/**
* Decrypt the data with the provided key
*
* @param string $data
* @param string $key
*
* @return false|string
*
* @throws EncryptionException
*/
protected function decryptWithKey($data, #[SensitiveParameter] $key)
{
// derive a secret key
$authKey = \hash_hkdf($this->digest, $this->key, 0, $this->authKeyInfo);
$authKey = \hash_hkdf($this->digest, $key, 0, $this->authKeyInfo);

$hmacLength = $this->rawData
? $this->digestSize[$this->digest]
Expand All @@ -152,7 +207,7 @@ public function decrypt($data, #[SensitiveParameter] $params = null)
}

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

return \openssl_decrypt($data, $this->cipher, $encryptKey, OPENSSL_RAW_DATA, $iv);
}
Expand Down
62 changes: 59 additions & 3 deletions system/Encryption/Handlers/SodiumHandler.php
Original file line number Diff line number Diff line change
Expand Up @@ -31,6 +31,13 @@ class SodiumHandler extends BaseHandler
*/
protected $key = '';

/**
* List of previous keys for fallback decryption.
*
* @var list<string>|string
*/
protected array|string $previousKeys = '';

/**
* Block size for padding message.
*
Expand Down Expand Up @@ -80,6 +87,56 @@ public function decrypt($data, #[SensitiveParameter] $params = null)
throw EncryptionException::forNeedsStarterKey();
}

// Only use fallback keys if no custom key was provided in params
$useFallback = ! isset($params['key']);

$attemptDecrypt = function ($key) use ($data): array {
try {
$result = $this->decryptWithKey($data, $key);
sodium_memzero($key);

return ['success' => true, 'data' => $result];
} catch (EncryptionException $e) {
sodium_memzero($key);

return ['success' => false, 'exception' => $e];
}
};

$result = $attemptDecrypt($this->key);

if ($result['success']) {
return $result['data'];
}

$originalException = $result['exception'];

// If primary key failed and fallback is allowed, try previous keys
if ($useFallback && ! in_array($this->previousKeys, ['', '0', []], true)) {
foreach ($this->previousKeys as $previousKey) {
$fallbackResult = $attemptDecrypt($previousKey);

if ($fallbackResult['success']) {
return $fallbackResult['data'];
}
}
}

throw $originalException;
}

/**
* Decrypt the data with the provided key
*
* @param string $data
* @param string $key
*
* @return string
*
* @throws EncryptionException
*/
protected function decryptWithKey($data, #[SensitiveParameter] $key)
{
if (mb_strlen($data, '8bit') < (SODIUM_CRYPTO_SECRETBOX_NONCEBYTES + SODIUM_CRYPTO_SECRETBOX_MACBYTES)) {
// message was truncated
throw EncryptionException::forAuthenticationFailed();
Expand All @@ -90,7 +147,7 @@ public function decrypt($data, #[SensitiveParameter] $params = null)
$ciphertext = self::substr($data, SODIUM_CRYPTO_SECRETBOX_NONCEBYTES);

// decrypt data
$data = sodium_crypto_secretbox_open($ciphertext, $nonce, $this->key);
$data = sodium_crypto_secretbox_open($ciphertext, $nonce, $key);

if ($data === false) {
// message was tampered in transit
Expand All @@ -106,7 +163,6 @@ public function decrypt($data, #[SensitiveParameter] $params = null)

// cleanup buffers
sodium_memzero($ciphertext);
sodium_memzero($this->key);

return $data;
}
Expand All @@ -120,7 +176,7 @@ public function decrypt($data, #[SensitiveParameter] $params = null)
*
* @throws EncryptionException If key is empty
*/
protected function parseParams($params)
protected function parseParams(#[SensitiveParameter] $params)
{
if ($params === null) {
return;
Expand Down
Loading