Skip to content

Commit 55566bc

Browse files
committed
align Encryptable and HasEncryptedEmail behavior - encrypt on set, decrypt on access, prevent double encryption
1 parent 796786c commit 55566bc

File tree

2 files changed

+163
-83
lines changed

2 files changed

+163
-83
lines changed

src/Encryptable.php

Lines changed: 93 additions & 71 deletions
Original file line numberDiff line numberDiff line change
@@ -24,56 +24,35 @@ public function getEncryptableAttributes(): array
2424
*/
2525
protected static function bootEncryptable()
2626
{
27-
static::saving(function ($model) {
28-
$model->encryptAttributes();
29-
});
30-
31-
static::retrieved(function ($model) {
32-
$model->decryptAttributes();
33-
});
27+
// Since we now encrypt immediately on set, we don't need saving event
28+
// Since we decrypt on access without modifying state, we don't need retrieved event
3429

35-
// Also decrypt when models are created from arrays (like from relationships)
36-
static::creating(function ($model) {
37-
// Don't decrypt on creating, just mark that we need to check later
38-
});
30+
// Keep this minimal - encryption happens on __set(), decryption on __get/getAttribute
3931
}
4032

4133
/**
4234
* Encrypt attributes marked as encryptable.
35+
* DEPRECATED: This method is no longer used since encryption happens immediately in __set().
4336
*
4437
* @return void
4538
*/
4639
protected function encryptAttributes()
4740
{
48-
$databaseHasLimits = DB::connection()->getDriverName() === 'pgsql';
49-
50-
foreach ($this->getEncryptableAttributes() as $attribute) {
51-
if (isset($this->attributes[$attribute]) && ! empty($this->attributes[$attribute])) {
52-
// Only encrypt if the value is not already encrypted
53-
if (!$this->isValueEncrypted($this->attributes[$attribute])) {
54-
// Use the field-specific encryption that handles character limits
55-
$this->attributes[$attribute] = $this->encryptValueForStorage($this->attributes[$attribute], $databaseHasLimits);
56-
}
57-
}
58-
}
41+
// This method is deprecated and no longer used.
42+
// Encryption now happens immediately in __set() method, aligning with HasEncryptedEmail behavior.
5943
}
6044

6145
/**
6246
* Decrypt attributes marked as encryptable.
47+
* DEPRECATED: This method is no longer used since we decrypt on access without modifying state.
6348
*
6449
* @return void
6550
*/
6651
protected function decryptAttributes()
6752
{
68-
foreach ($this->getEncryptableAttributes() as $attribute) {
69-
if (isset($this->attributes[$attribute]) && ! empty($this->attributes[$attribute])) {
70-
try {
71-
$this->attributes[$attribute] = $this->decryptValue($this->attributes[$attribute]);
72-
} catch (\Exception $e) {
73-
// If the value can't be decrypted, leave it as is
74-
}
75-
}
76-
}
53+
// This method is deprecated and no longer used.
54+
// Decryption now happens on-demand in __get() and getAttribute() without modifying $this->attributes
55+
// This prevents double encryption issues and aligns with HasEncryptedEmail behavior
7756
}
7857

7958
/**
@@ -134,8 +113,7 @@ protected function decryptValue($value)
134113

135114
/**
136115
* Dynamically retrieve attributes.
137-
* This ensures attributes are decrypted when accessed via notifications
138-
* and other systems that might bypass the standard attribute getters.
116+
* Returns decrypted values without modifying internal state.
139117
*
140118
* @param string $key
141119
* @return mixed
@@ -147,21 +125,24 @@ public function __get($key)
147125
return parent::__get($key);
148126
}
149127

150-
// If the attribute exists and is encrypted, ensure it's decrypted
128+
// If the attribute exists and is encrypted, return decrypted value
151129
$encryptableAttributes = $this->getEncryptableAttributes();
152130

153131
if (in_array($key, $encryptableAttributes) && isset($this->attributes[$key])) {
154132
if (is_string($this->attributes[$key]) && !empty($this->attributes[$key])) {
155133
// Check if the value appears to be encrypted
156134
if ($this->isValueEncrypted($this->attributes[$key])) {
157135
try {
136+
// Return decrypted value directly - DO NOT modify $this->attributes
158137
return $this->decryptValue($this->attributes[$key]);
159138
} catch (\Exception $e) {
160139
// If decryption fails, return the original value
161140
return $this->attributes[$key];
162141
}
163142
}
164143
}
144+
// If not encrypted, return as-is
145+
return $this->attributes[$key];
165146
}
166147

167148
// Default behavior if not an encrypted field
@@ -170,7 +151,7 @@ public function __get($key)
170151

171152
/**
172153
* Dynamically set attributes.
173-
* Ensures attributes are stored as plain text when set, to be encrypted on save.
154+
* Encrypts immediately when set, like HasEncryptedEmail trait.
174155
*
175156
* @param string $key
176157
* @param mixed $value
@@ -183,28 +164,52 @@ public function __set($key, $value)
183164
return parent::__set($key, $value);
184165
}
185166

186-
// Set the attribute as normal - do NOT encrypt here
187-
// Encryption should only happen on save via the saving event
167+
$encryptableAttributes = $this->getEncryptableAttributes();
168+
169+
if (in_array($key, $encryptableAttributes)) {
170+
if (!empty($value)) {
171+
// Check if value is already encrypted to avoid double-encryption
172+
if (!$this->isValueEncrypted($value)) {
173+
// Encrypt the value immediately
174+
$databaseHasLimits = DB::connection()->getDriverName() === 'pgsql';
175+
$this->attributes[$key] = $this->encryptValueForStorage($value, $databaseHasLimits);
176+
} else {
177+
// Already encrypted, use as is
178+
$this->attributes[$key] = $value;
179+
}
180+
} else {
181+
$this->attributes[$key] = null;
182+
}
183+
return;
184+
}
185+
186+
// For non-encryptable attributes, use default behavior
188187
parent::__set($key, $value);
189188
}
190189

191190
/**
192191
* Convert the model's attributes to an array.
193-
* Ensures all attributes are properly decrypted.
192+
* Ensures encrypted attributes are decrypted by going through getAttribute().
194193
*
195194
* @return array
196195
*/
197196
public function attributesToArray()
198197
{
199-
// Make sure all encryptable attributes are decrypted
200-
$this->decryptAttributes();
198+
$attributes = parent::attributesToArray();
201199

202-
return parent::attributesToArray();
200+
// For encryptable attributes, make sure we get the decrypted values
201+
foreach ($this->getEncryptableAttributes() as $key) {
202+
if (array_key_exists($key, $attributes)) {
203+
$attributes[$key] = $this->getAttribute($key);
204+
}
205+
}
206+
207+
return $attributes;
203208
}
204209

205210
/**
206211
* Get an attribute from the model.
207-
* Ensures encrypted attributes are properly decrypted.
212+
* Returns decrypted values without modifying internal state.
208213
*
209214
* @param string $key
210215
* @return mixed
@@ -223,10 +228,8 @@ public function getAttribute($key)
223228
if (is_string($this->attributes[$key]) && !empty($this->attributes[$key])) {
224229
if ($this->isValueEncrypted($this->attributes[$key])) {
225230
try {
226-
$decrypted = $this->decryptValue($this->attributes[$key]);
227-
// Store the decrypted value back to avoid repeated decryption
228-
$this->attributes[$key] = $decrypted;
229-
return $decrypted;
231+
// Return decrypted value directly - DO NOT modify $this->attributes
232+
return $this->decryptValue($this->attributes[$key]);
230233
} catch (\Exception $e) {
231234
// If decryption fails, return the attribute as-is
232235
return $this->attributes[$key];
@@ -244,6 +247,7 @@ public function getAttribute($key)
244247
/**
245248
* Check if a value appears to be encrypted.
246249
* Detects both Laravel standard encryption (AES-256-CBC) and compact encryption formats.
250+
* Enhanced with bulletproof double encryption protection.
247251
*
248252
* @param string $value
249253
* @return bool
@@ -256,37 +260,55 @@ protected function isValueEncrypted($value)
256260

257261
// Check for compact encryption format (starts with 'c:')
258262
if (strpos($value, 'c:') === 0) {
259-
return true;
263+
// Validate compact format: c:base64.base64.base64
264+
$parts = explode('.', substr($value, 2));
265+
if (count($parts) === 3) {
266+
// All parts should be valid base64
267+
foreach ($parts as $part) {
268+
if (base64_decode($part, true) === false) {
269+
return false;
270+
}
271+
}
272+
return true;
273+
}
274+
return false;
260275
}
261276

262277
// Check for Laravel standard encryption format (base64 encoded JSON with iv, value, mac)
263-
// Laravel AES-256-CBC encryption produces base64 strings that decode to JSON
264-
if (strlen($value) > 50 && base64_decode($value, true) !== false) {
265-
$decoded = base64_decode($value, true);
266-
if ($decoded !== false) {
267-
$json = json_decode($decoded, true);
268-
// Laravel encryption has 'iv', 'value', and 'mac' keys
269-
// May also have additional keys like 'tag' in newer versions
270-
if (is_array($json) &&
271-
isset($json['iv'], $json['value'], $json['mac'])) {
272-
return true;
273-
}
274-
}
278+
// Must be long enough to contain encryption data
279+
if (strlen($value) < 100) {
280+
return false;
275281
}
276282

277-
// Additional pattern check for Laravel encryption that starts with 'eyJ'
278-
// (base64 encoding of '{"' which is common for Laravel encryption JSON)
279-
if (preg_match('/^eyJ[A-Za-z0-9+\/]+=*$/', $value)) {
280-
$decoded = base64_decode($value, true);
281-
if ($decoded !== false) {
282-
$json = json_decode($decoded, true);
283-
if (is_array($json) &&
284-
isset($json['iv'], $json['value'], $json['mac'])) {
285-
return true;
286-
}
287-
}
283+
// Must be valid base64
284+
$decoded = base64_decode($value, true);
285+
if ($decoded === false) {
286+
return false;
287+
}
288+
289+
// Must be valid JSON
290+
$json = json_decode($decoded, true);
291+
if (!is_array($json)) {
292+
return false;
293+
}
294+
295+
// Must have required Laravel encryption keys
296+
if (!isset($json['iv'], $json['value'], $json['mac'])) {
297+
return false;
298+
}
299+
300+
// All components should be base64 strings
301+
if (!is_string($json['iv']) || !is_string($json['value']) || !is_string($json['mac'])) {
302+
return false;
303+
}
304+
305+
// Validate that components are valid base64
306+
if (base64_decode($json['iv'], true) === false ||
307+
base64_decode($json['value'], true) === false ||
308+
base64_decode($json['mac'], true) === false) {
309+
return false;
288310
}
289311

290-
return false;
312+
return true;
291313
}
292314
}

tests/Unit/EncryptableTest.php

Lines changed: 70 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -434,7 +434,7 @@ public function testDoubleEncryptionDecryption()
434434
$this->assertNotEquals($originalValue, $rawData->address);
435435
}
436436

437-
public function testSingleEncryptionOnSaveOnly()
437+
public function testImmediateEncryptionOnSet()
438438
{
439439
// Create a test table
440440
$this->app['db']->connection()->getSchemaBuilder()->create('test_models', function ($table) {
@@ -447,34 +447,39 @@ public function testSingleEncryptionOnSaveOnly()
447447
// Create a new model
448448
$model = new TestEncryptableModel();
449449

450-
// Set values - these should NOT be encrypted yet
450+
// Set values - these should be encrypted IMMEDIATELY
451451
$model->email = '[email protected]';
452452
$model->phone = '123-456-7890';
453453

454-
// Check that the values are still plain text before save
454+
// Check that accessing the values returns the original plain text
455455
$this->assertEquals('[email protected]', $model->email);
456456
$this->assertEquals('123-456-7890', $model->phone);
457457

458-
// Check the raw attributes are also plain text
459-
$this->assertEquals('[email protected]', $model->getAttributes()['email']);
460-
$this->assertEquals('123-456-7890', $model->getAttributes()['phone']);
458+
// Check that the raw attributes are actually encrypted
459+
$rawAttributes = $model->getAttributes();
460+
$this->assertNotEquals('[email protected]', $rawAttributes['email']);
461+
$this->assertNotEquals('123-456-7890', $rawAttributes['phone']);
461462

462-
// Now save the model - this should encrypt the values
463+
// Verify they are detectable as encrypted
464+
$reflection = new \ReflectionClass($model);
465+
$method = $reflection->getMethod('isValueEncrypted');
466+
$method->setAccessible(true);
467+
$this->assertTrue($method->invokeArgs($model, [$rawAttributes['email']]));
468+
$this->assertTrue($method->invokeArgs($model, [$rawAttributes['phone']]));
469+
470+
// Now save the model - values should already be encrypted, no additional encryption
463471
$model->save();
464472

465473
// After save, when accessed, they should still return the original values
466474
$this->assertEquals('[email protected]', $model->email);
467475
$this->assertEquals('123-456-7890', $model->phone);
468476

469-
// But the raw database should be encrypted
477+
// Database should contain the same encrypted values as in memory
470478
$rawData = $this->app['db']->connection()->table('test_models')->where('id', $model->id)->first();
471479
$this->assertNotEquals('[email protected]', $rawData->email);
472480
$this->assertNotEquals('123-456-7890', $rawData->phone);
473481

474-
// And they should be detectable as encrypted
475-
$reflection = new \ReflectionClass($model);
476-
$method = $reflection->getMethod('isValueEncrypted');
477-
$method->setAccessible(true);
482+
// Values should still be encrypted in the same way
478483
$this->assertTrue($method->invokeArgs($model, [$rawData->email]));
479484
$this->assertTrue($method->invokeArgs($model, [$rawData->phone]));
480485

@@ -483,6 +488,59 @@ public function testSingleEncryptionOnSaveOnly()
483488
$this->assertEquals('[email protected]', $freshModel->email);
484489
$this->assertEquals('123-456-7890', $freshModel->phone);
485490
}
491+
492+
public function testNoDoubleEncryptionOnMultipleSaves()
493+
{
494+
// Create a test table
495+
$this->app['db']->connection()->getSchemaBuilder()->create('test_models', function ($table) {
496+
$table->increments('id');
497+
$table->text('email')->nullable();
498+
$table->text('phone')->nullable();
499+
$table->timestamps();
500+
});
501+
502+
// Create and save a model
503+
$model = new TestEncryptableModel();
504+
$model->email = '[email protected]';
505+
$model->phone = '123-456-7890';
506+
$model->save();
507+
508+
// Get the encrypted values after first save
509+
$firstRawData = $this->app['db']->connection()->table('test_models')->where('id', $model->id)->first();
510+
$firstEncryptedEmail = $firstRawData->email;
511+
$firstEncryptedPhone = $firstRawData->phone;
512+
513+
// Access the values (which should decrypt them for display)
514+
$this->assertEquals('[email protected]', $model->email);
515+
$this->assertEquals('123-456-7890', $model->phone);
516+
517+
// Save again - this should NOT cause re-encryption
518+
$model->save();
519+
520+
// Get the encrypted values after second save
521+
$secondRawData = $this->app['db']->connection()->table('test_models')->where('id', $model->id)->first();
522+
523+
// The encrypted values should be identical (no double encryption)
524+
$this->assertEquals($firstEncryptedEmail, $secondRawData->email);
525+
$this->assertEquals($firstEncryptedPhone, $secondRawData->phone);
526+
527+
// Values should still decrypt correctly
528+
$this->assertEquals('[email protected]', $model->email);
529+
$this->assertEquals('123-456-7890', $model->phone);
530+
531+
// Try setting the same values again and saving
532+
$model->email = '[email protected]';
533+
$model->phone = '123-456-7890';
534+
$model->save();
535+
536+
// Should still be properly encrypted and decryptable
537+
$thirdRawData = $this->app['db']->connection()->table('test_models')->where('id', $model->id)->first();
538+
$this->assertNotEquals('[email protected]', $thirdRawData->email);
539+
$this->assertNotEquals('123-456-7890', $thirdRawData->phone);
540+
541+
$this->assertEquals('[email protected]', $model->email);
542+
$this->assertEquals('123-456-7890', $model->phone);
543+
}
486544
}
487545

488546
class TestEncryptableModel extends Model

0 commit comments

Comments
 (0)