@@ -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}
0 commit comments