I setup encryption using a trait and a key manager (using chatGPT).
Trait:
<?php
namespace AppTraits;
use AppServicesKeyManager;
use IlluminateSupportFacadesCache;
trait Encryptable
{
protected $keyManager;
public function __construct(array $attributes = [])
{
parent::__construct($attributes);
$this->keyManager = new KeyManager();
}
/**
* Boot the trait.
*
* @return void
*/
public static function bootEncryptable()
{
static::saving(function ($model) {
$model->encryptAttributes();
});
static::retrieved(function ($model) {
$model->decryptAttributes();
});
}
/**
* Encrypt attributes before saving.
*
* @return void
*/
public function encryptAttributes()
{
foreach ($this->getEncryptableAttributes() as $attribute) {
if ((! empty($this->{$attribute}) && ! $this->isEncrypted($this->{$attribute}))
|| $this->isDirty($attribute)) { // if attribute changes
$this->{$attribute} = $this->encryptAttribute($this->{$attribute});
}
}
}
/**
* Decrypt attributes after retrieving.
*
* @return void
*/
public function decryptAttributes()
{
foreach ($this->getEncryptableAttributes() as $attribute) {
if (! empty($this->{$attribute}) && $this->isEncrypted($this->{$attribute})) {
$this->{$attribute} = $this->decryptAttribute($this->{$attribute});
}
}
}
/**
* Encrypt a single attribute value.
*
* @param mixed $value
* @return string|null
*/
protected function encryptAttribute($value)
{
return $this->keyManager->encrypt($value);
}
/**
* Decrypt a single attribute value.
*
* @param string $value
* @return mixed
*/
protected function decryptAttribute($value)
{
$cacheKey = $this->getCacheKeyForDecryptedValue($value);
if (Cache::has($cacheKey)) {
return Cache::get($cacheKey);
}
return Cache::remember($cacheKey, now()->addMinutes(30), function () use ($value) {
return $this->keyManager->decrypt($value);
});
}
/**
* Get the cache key for a decrypted attribute value.
*
* @param string $encryptedValue
* @return string
*/
protected function getCacheKeyForDecryptedValue($encryptedValue)
{
return 'decrypted_'.md5($encryptedValue);
}
/**
* Get the attributes that should be encrypted.
*
* @return array
*/
protected function getEncryptableAttributes()
{
return property_exists($this, 'encryptable') ? $this->encryptable : null;
}
/**
* Check if the attributes is already encrypted.
*
* @return bool
*/
protected function isEncrypted($value)
{
// Check if the value is base64 encoded
if (base64_decode($value, true) === false) {
return false;
}
// Decode the base64 value
$decoded = base64_decode($value, true);
// Check for common encryption patterns (adjust if necessary for different libraries)
// This example assumes a JSON structure which contains "iv" and "value" keys
if (strpos($decoded, '{"iv":') === 0 || strpos($decoded, '{"iv":') !== false) {
return true;
}
return false;
}
}
Key manager:
<?php
namespace AppServices;
use IlluminateContractsEncryptionDecryptException;
use IlluminateSupportFacadesConfig;
use IlluminateSupportFacadesCrypt;
class KeyManager
{
protected $currentKey;
protected $previousKeys;
public function __construct()
{
$this->currentKey = Config::get('app.key');
$this->previousKeys = Config::get('app.previous_keys', []);
}
public function encrypt($data)
{
return Crypt::encryptString($data);
}
public function decrypt($encryptedData)
{
try {
return Crypt::decryptString($encryptedData);
} catch (DecryptException $e) {
foreach ($this->previousKeys as $key) {
try {
Config::set('app.key', $key);
return Crypt::decryptString($encryptedData);
} catch (DecryptException $e) {
continue;
}
}
Config::set('app.key', $this->currentKey);
throw new DecryptException('Decryption failed with all keys');
}
}
}
I have these passing tests:
Trait test
<?php
use AppModelsLoanProvider;
use IlluminateSupportFacadesConfig;
beforeEach(function () {
Config::set('app.key', 'base64:J63qRTDLub5NuZvP+kb8YIorGS6qFYHKVo6u7179stY=');
Config::set('app.previous_keys', [
'base64:2nLsGFGzyoae2ax3EF2Lyq/hH6QghBGLIq5uL+Gp8/w=',
]);
$this->loanProvider = LoanProvider::factory()->create(['auth_code' => 'Fake auth_code']);
});
it('encrypts and decrypts attributes automatically', function () {
$retrievedProvider = LoanProvider::find($this->loanProvider->id);
expect($retrievedProvider->auth_code)->toBe('Fake auth_code');
});
it('encrypts attribute before saving', function () {
$encryptedAttribute = $this->loanProvider->getAttributes()['auth_code'];
expect($encryptedAttribute)->not()->toBe('Fake auth_code');
});
it('decrypts attribute when retrieving', function () {
$retrievedProvider = LoanProvider::find($this->loanProvider->id);
expect($retrievedProvider->auth_code)->toBe('Fake auth_code');
});
key manager test:
<?php
use AppServicesKeyManager;
use IlluminateContractsEncryptionDecryptException;
use IlluminateSupportFacadesConfig;
beforeEach(function () {
// Mock Laravel configuration for keys
Config::set('app.key', 'base64:J63qRTDLub5NuZvP+kb8YIorGS6qFYHKVo6u7179stY=');
Config::set('app.previous_keys', [
'base64:2nLsGFGzyoae2ax3EF2Lyq/hH6QghBGLIq5uL+Gp8/w=',
]);
$this->keyManager = new KeyManager();
});
it('can encrypt and decrypt data with current key', function () {
$data = 'Sensitive Data';
$encrypted = $this->keyManager->encrypt($data);
expect($this->keyManager->decrypt($encrypted))->toBe($data);
});
it('can decrypt data with previous key', function () {
$data = 'Sensitive Data';
$oldKey = 'base64:2nLsGFGzyoae2ax3EF2Lyq/hH6QghBGLIq5uL+Gp8/w=';
Config::set('app.key', $oldKey);
$encrypted = $this->keyManager->encrypt($data);
Config::set('app.key', 'base64:J63qRTDLub5NuZvP+kb8YIorGS6qFYHKVo6u7179stY=');
expect($this->keyManager->decrypt($encrypted))->toBe($data);
});
it('throws an exception for invalid data', function () {
$invalidEncryptedData = 'invalid data';
expect(fn () => $this->keyManager->decrypt($invalidEncryptedData))
->toThrow(DecryptException::class);
});
I use the trait in models with attributes I want to encrypt and set the ‘encryptable’ property on it.
My expectations are that the selected attributes will be encrypted when the model is created and when specific attributes changes, and that I will be able to retrieve decrypted values when I do $model->encryptedAttribute.
Is this setup fit for purpose? I am experiencing situations where retrieval sometimes doesn’t decrypt attributes. For example, one test that gets the decrypted values it requires by making a call to another method passes but another test making a call to the same method fails because encrypted values are returned. Also, for decryption to work, I have to first make an explicit call with find(), get(), etc. If I create a model and assign it to a variable, $model->attribute will return an encrypted value-is this correct behaviour?
1