From a506a34725b0dd1d375c5dfe68e232b3c6d03cdd Mon Sep 17 00:00:00 2001 From: Jasper Zonneveld Date: Mon, 4 Mar 2024 10:16:45 +0100 Subject: [PATCH 1/9] feat: add migration guide and command --- MIGRATING.md | 40 ++++++ README.md | 14 +- src/Commands/ReEncryptModels.php | 189 +++++++++++++++++++++++++++ src/EncryptedDataServiceProvider.php | 7 + src/EncryptedModel.php | 4 + src/ModelEncrypter.php | 65 +++++++++ tests/ModelEncrypterTest.php | 131 +++++++++++++++++++ 7 files changed, 443 insertions(+), 7 deletions(-) create mode 100644 MIGRATING.md create mode 100644 src/Commands/ReEncryptModels.php create mode 100644 src/ModelEncrypter.php create mode 100644 tests/ModelEncrypterTest.php diff --git a/MIGRATING.md b/MIGRATING.md new file mode 100644 index 0000000..16c4286 --- /dev/null +++ b/MIGRATING.md @@ -0,0 +1,40 @@ +# Migrating swisnl/laravel-encrypted-data + +## To Laravel Encrypted Casting +The main difference between this package and [Laravel Encrypted Casting](https://laravel.com/docs/eloquent-mutators#encrypted-casting) is that this package serializes the data before encrypting it, while Laravel Encrypted Casting encrypts the data directly. This means that the data is not compatible between the two packages. In order to migrate from this package to Laravel Encrypted Casting, you will need to decrypt the data and then re-encrypt it using Laravel Encrypted Casting. Here is a step-by-step guide on how to do this: + +[//]: # (TODO: What to do when you need serialized data or encrypted dates?) + +1. Make sure you're running on Laravel 11 or higher. +2. Remove the `Swis\Laravel\Encrypted\EncryptedModel` from your models and replace it with `Illuminate\Database\Eloquent\Model`: +```diff +- use Swis\Laravel\Encrypted\EncryptedModel ++ use Illuminate\Database\Eloquent\Model + +- class YourEncryptedModel extends EncryptedModel ++ class YourEncryptedModel extends Model +``` +3. Set up Encrypted Casting: +```diff +- protected $encrypted = [ +- 'secret', +- ]; ++ protected $casts = [ ++ 'secret' => 'encrypted', ++ ]; +``` +4. Set up our custom model encrypter in your `AppServiceProvider`: +```php +public function boot(): void +{ + $modelEncrypter = new \Swis\Laravel\Encrypted\ModelEncrypter(); + YourEncryptedModel::encryptUsing($modelEncrypter); + // ... all your other models that used to extend \Swis\Laravel\Encrypted\EncryptedModel +} +``` +5. Run our re-encryption command: +```bash +php artisan encrypted-data:re-encrypt:models --quietly --no-touch +``` +N.B. Use `--help` to see all available options and modify as needed! +6. Remove our custom model encrypter from your `AppServiceProvider` (step 4). diff --git a/README.md b/README.md index 8b650fe..b2e6538 100644 --- a/README.md +++ b/README.md @@ -15,8 +15,8 @@ This package contains several Laravel utilities to work with encrypted data. Via Composer -``` bash -$ composer require swisnl/laravel-encrypted-data +```bash +composer require swisnl/laravel-encrypted-data ``` ## Usage @@ -26,12 +26,12 @@ $ composer require swisnl/laravel-encrypted-data > [!WARNING] > Laravel supports [encrypted casts](https://laravel.com/docs/10.x/eloquent-mutators#encrypted-casting) since version 8.12, so new projects should use that instead of the models provided by this package. > -> We aim to provide a migration path to encrypted casts. See issue [#1](https://github.com/swisnl/laravel-encrypted-data/issues/1) for more information. +> Please see [MIGRATING](MIGRATING.md) for a step-by-step guide on how to migrate. > Extend `\Swis\Laravel\Encrypted\EncryptedModel` in your model and define the encrypted fields. Make sure your database columns are long enough, so your data isn't truncated! -``` php +```php protected $encrypted = [ 'secret', ]; @@ -43,7 +43,7 @@ You can now simply use the model properties as usual and everything will be encr Configure the storage driver in `config/filesystems.php`. -``` php +```php 'disks' => [ 'local' => [ 'driver' => 'local-encrypted', @@ -76,8 +76,8 @@ Please see [CHANGELOG](CHANGELOG.md) for more information on what has changed re ## Testing -``` bash -$ composer test +```bash +composer test ``` ## Contributing diff --git a/src/Commands/ReEncryptModels.php b/src/Commands/ReEncryptModels.php new file mode 100644 index 0000000..606fe3d --- /dev/null +++ b/src/Commands/ReEncryptModels.php @@ -0,0 +1,189 @@ +models(); + + if ($models->isEmpty()) { + $this->warn('No models found.'); + + return self::FAILURE; + } + + if (!$this->modelsCanBeReEncrypted($models)) { + $this->error('Not all models can be re-encrypted because a previous key has not been set up. Please set APP_PREVIOUS_KEYS first!'); + + return self::FAILURE; + } + + if ($this->option('force') === false && $this->confirm('The following models will be re-encrypted: '.PHP_EOL.$models->implode(PHP_EOL).PHP_EOL.'Do you want to continue?') === false) { + return self::FAILURE; + } + + $models->each(function (string $model) { + $this->line("Re-encrypting {$model}..."); + $this->reEncryptModels($model); + }); + + $this->info('Re-encrypting done!'); + + return self::SUCCESS; + } + + /** + * @param class-string<\Illuminate\Database\Eloquent\Model> $modelClass + */ + protected function reEncryptModels(string $modelClass): void + { + $modelClass::unguarded(function () use ($modelClass) { + $modelClass::query() + ->when($this->option('with-trashed') && in_array(SoftDeletes::class, class_uses_recursive($modelClass), true), function ($query) { + $query->withTrashed(); + }) + ->eachById( + function (Model $model) { + if ($this->option('no-touch')) { + $model->timestamps = false; + } + + // Set each encrypted attribute to trigger re-encryption + collect($model->getCasts()) + ->filter(fn (string $cast): bool => (bool) preg_match($this->option('casts'), $cast)) + ->keys() + ->each(fn ($key) => $model->setAttribute($key, $model->getAttribute($key))); + + if ($this->option('quietly')) { + $model->saveQuietly(); + } else { + $model->save(); + } + }, + $this->option('chunk') + ); + }); + } + + /** + * Determine the models that should be re-encrypted. + * + * @return \Illuminate\Support\Collection> + */ + protected function models(): Collection + { + if (!empty($this->option('model')) && !empty($this->option('except'))) { + throw new \InvalidArgumentException('The --models and --except options cannot be combined.'); + } + + if (!empty($models = $this->option('model'))) { + return collect($models) + ->map(fn (string $modelClass): string => $this->normalizeModelClass($modelClass)) + ->each(function (string $modelClass): void { + if (!class_exists($modelClass)) { + throw new \InvalidArgumentException(sprintf('Model class %s does not exist.', $modelClass)); + } + if (!is_a($modelClass, Model::class, true)) { + throw new \InvalidArgumentException(sprintf('Class %s is not a model.', $modelClass)); + } + }); + } + + if (!empty($except = $this->option('except'))) { + $except = array_map(fn (string $modelClass): string => $this->normalizeModelClass($modelClass), $except); + } + + return collect(Finder::create()->in($this->getModelsPath())->files()->name('*.php')) + ->map(function (SplFileInfo $modelFile): string { + $namespace = $this->laravel->getNamespace(); + + return $namespace.str_replace( + [DIRECTORY_SEPARATOR, '.php'], + ['\\', ''], + Str::after($modelFile->getRealPath(), realpath(app_path()).DIRECTORY_SEPARATOR) + ); + }) + ->when(!empty($except), fn (Collection $modelClasses): Collection => $modelClasses->reject(fn (string $modelClass) => in_array($modelClass, $except, true))) + ->filter(fn (string $modelClass): bool => class_exists($modelClass) && is_a($modelClass, Model::class, true) && !(new \ReflectionClass($modelClass))->isAbstract()) + ->reject(function (string $modelClass): bool { + $model = new $modelClass(); + + return collect($model->getCasts()) + ->filter(fn (string $cast): bool => (bool) preg_match($this->option('casts'), $cast)) + ->isEmpty(); + }) + ->values(); + } + + /** + * Get the path where models are located. + * + * @return string[]|string + */ + protected function getModelsPath(): string|array + { + if (!empty($path = $this->option('path'))) { + return collect($path) + ->map(fn (string $path): string => is_dir($path) ? $path : base_path($path)) + ->each(function (string $path): void { + if (!is_dir($path)) { + throw new \InvalidArgumentException(sprintf('The path %s is not a directory.', $path)); + } + }) + ->all(); + } + + return is_dir($path = app_path('Models')) ? $path : app_path(); + } + + /** + * Get the namespace of models. + */ + protected function getModelsNamespace(): string + { + return is_dir(app_path('Models')) ? $this->laravel->getNamespace().'Models\\' : $this->laravel->getNamespace(); + } + + /** + * Make sure the model class is a FQCN. + */ + protected function normalizeModelClass(string $modelClass): string + { + return str_starts_with($modelClass, $this->getModelsNamespace()) || str_starts_with($modelClass, '\\'.$this->getModelsNamespace()) ? ltrim($modelClass, '\\') : $this->getModelsNamespace().$modelClass; + } + + /** + * Check if the models are properly configured to be re-encrypted. + * This requires a previous key to be set on the encrypter. + * + * @param \Illuminate\Support\Collection> $models + */ + protected function modelsCanBeReEncrypted(Collection $models): bool + { + return $models->every(fn (string $model): bool => !empty(($model::$encrypter ?? Crypt::getFacadeRoot())->getPreviousKeys())); + } +} diff --git a/src/EncryptedDataServiceProvider.php b/src/EncryptedDataServiceProvider.php index 5e31eba..d1c8b2b 100644 --- a/src/EncryptedDataServiceProvider.php +++ b/src/EncryptedDataServiceProvider.php @@ -12,6 +12,7 @@ use League\Flysystem\UnixVisibility\PortableVisibilityConverter; use League\Flysystem\Visibility; use Swis\Flysystem\Encrypted\EncryptedFilesystemAdapter; +use Swis\Laravel\Encrypted\Commands\ReEncryptModels; class EncryptedDataServiceProvider extends ServiceProvider { @@ -28,6 +29,12 @@ protected function registerEncrypter(): void public function boot(): void { $this->setupStorageDriver(); + + if ($this->app->runningInConsole()) { + $this->commands([ + ReEncryptModels::class, + ]); + } } protected function setupStorageDriver(): void diff --git a/src/EncryptedModel.php b/src/EncryptedModel.php index 24053cd..98a32f4 100644 --- a/src/EncryptedModel.php +++ b/src/EncryptedModel.php @@ -6,6 +6,10 @@ use Illuminate\Database\Eloquent\Model; use Illuminate\Support\Str; +/** + * @deprecated use Laravel's built-in encrypted casting instead, this class will be removed in a future version + * @see ../MIGRATING.md for a step-by-step guide on how to migrate + */ class EncryptedModel extends Model { /** diff --git a/src/ModelEncrypter.php b/src/ModelEncrypter.php new file mode 100644 index 0000000..ed21470 --- /dev/null +++ b/src/ModelEncrypter.php @@ -0,0 +1,65 @@ +encrypter = $encrypter ?? app('encrypted-data.encrypter'); + // Generate a dummy previous key. This key is never actually used, + // but then Laravel assumes the value should be re-encrypted. + // This makes it easy to re-encrypt model attributes. + $this->previousKeys[] = $previousKey ?? \Illuminate\Encryption\Encrypter::generateKey(config('app.cipher')); + } + + public function encrypt(#[\SensitiveParameter] $value, $serialize = true) + { + return $this->encrypter->encrypt($value, $serialize); + } + + public function decrypt($payload, $unserialize = true) + { + if ($unserialize) { + return $this->encrypter->decrypt($payload); + } + + $decrypted = $this->encrypter->decrypt($payload, false); + + $unserialized = @unserialize($decrypted); + if ($unserialized === false && $decrypted !== 'b:0;') { + return $decrypted; + } + + return $unserialized; + } + + public function getKey() + { + return $this->encrypter->getKey(); + } + + public function getAllKeys() + { + return [ + $this->getKey(), + ...$this->getPreviousKeys(), + ]; + } + + public function getPreviousKeys() + { + return [ + ...$this->encrypter->getPreviousKeys(), + ...$this->previousKeys, + ]; + } +} diff --git a/tests/ModelEncrypterTest.php b/tests/ModelEncrypterTest.php new file mode 100644 index 0000000..5547bc8 --- /dev/null +++ b/tests/ModelEncrypterTest.php @@ -0,0 +1,131 @@ +mockEncrypter = $this->createMock(EncrypterContract::class); + $this->modelEncrypter = new ModelEncrypter($this->mockEncrypter, 'dummy-previous-key'); + } + + public function testEncryptDelegatesToEncrypter(): void + { + $this->mockEncrypter->expects($this->once()) + ->method('encrypt') + ->with('foo', true) + ->willReturn('encrypted-foo'); + + $result = $this->modelEncrypter->encrypt('foo', true); + + $this->assertEquals('encrypted-foo', $result); + } + + public function testDecryptWithUnserializeTrue(): void + { + $this->mockEncrypter->expects($this->once()) + ->method('decrypt') + ->with('payload') + ->willReturn('bar'); + + $result = $this->modelEncrypter->decrypt('payload', true); + + $this->assertEquals('bar', $result); + } + + public function testDecryptWithUnserializeFalseAndSerializedValue(): void + { + $this->mockEncrypter->expects($this->once()) + ->method('decrypt') + ->with('payload', false) + ->willReturn('a:1:{i:0;s:3:"baz";}'); + + $result = $this->modelEncrypter->decrypt('payload', false); + + $this->assertEquals(['baz'], $result); + } + + public function testDecryptWithUnserializeFalseAndNonSerializedValue(): void + { + $this->mockEncrypter->expects($this->once()) + ->method('decrypt') + ->with('payload', false) + ->willReturn('not-serialized'); + + $result = $this->modelEncrypter->decrypt('payload', false); + + $this->assertEquals('not-serialized', $result); + } + + public function testDecryptWithUnserializeFalseAndSerializedFalseValue(): void + { + $this->mockEncrypter->expects($this->once()) + ->method('decrypt') + ->with('payload', false) + ->willReturn('b:0;'); + + $result = $this->modelEncrypter->decrypt('payload', false); + + $this->assertFalse($result); + } + + public function testGetKeyDelegates(): void + { + $this->mockEncrypter->expects($this->once()) + ->method('getKey') + ->willReturn('key123'); + + $this->assertEquals('key123', $this->modelEncrypter->getKey()); + } + + public function testGetPreviousKeysMergesWithDummy(): void + { + if (version_compare(Application::VERSION, '11.0.0', '<')) { + $this->markTestSkipped('The test requires Laravel 11 or higher to run.'); + } + + $this->mockEncrypter->expects($this->once()) + ->method('getPreviousKeys') + ->willReturn(['prev1', 'prev2']); + + $result = $this->modelEncrypter->getPreviousKeys(); + + $this->assertCount(3, $result); // includes dummy + $this->assertContains('prev1', $result); + $this->assertContains('prev2', $result); + $this->assertContains('dummy-previous-key', $result); + } + + public function testGetAllKeysMergesCurrentAndPrevious(): void + { + if (version_compare(Application::VERSION, '11.0.0', '<')) { + $this->markTestSkipped('The test requires Laravel 11 or higher to run.'); + } + + $this->mockEncrypter->expects($this->once()) + ->method('getKey') + ->willReturn('key123'); + $this->mockEncrypter->expects($this->once()) + ->method('getPreviousKeys') + ->willReturn(['prev1']); + + $result = $this->modelEncrypter->getAllKeys(); + + $this->assertCount(3, $result); // includes dummy + $this->assertContains('key123', $result); + $this->assertContains('prev1', $result); + $this->assertContains('dummy-previous-key', $result); + } +} From ff4716df048c2abba79d33aaa652f169d101fcf7 Mon Sep 17 00:00:00 2001 From: Jasper Zonneveld Date: Sun, 8 Jun 2025 11:55:00 +0200 Subject: [PATCH 2/9] feat: add support for encrypted date(time) --- MIGRATING.md | 21 +- README.md | 25 +- src/Casts/AsEncryptedDate.php | 29 +++ src/Casts/AsEncryptedDateTime.php | 27 ++ src/Casts/AsEncryptedImmutableDate.php | 29 +++ src/Casts/AsEncryptedImmutableDateTime.php | 29 +++ src/Casts/EncryptedDateTime.php | 123 ++++++++++ src/Commands/ReEncryptModels.php | 2 + tests/Casts/AsEncryptedDateTest.php | 45 ++++ tests/Casts/AsEncryptedDateTimeTest.php | 45 ++++ tests/Casts/AsEncryptedImmutableDateTest.php | 45 ++++ .../AsEncryptedImmutableDateTimeTest.php | 45 ++++ tests/Casts/EncryptedDateTimeTest.php | 232 ++++++++++++++++++ tests/_mocks/DummyEncrypter.php | 30 +++ tests/_mocks/Model.php | 8 + 15 files changed, 719 insertions(+), 16 deletions(-) create mode 100644 src/Casts/AsEncryptedDate.php create mode 100644 src/Casts/AsEncryptedDateTime.php create mode 100644 src/Casts/AsEncryptedImmutableDate.php create mode 100644 src/Casts/AsEncryptedImmutableDateTime.php create mode 100644 src/Casts/EncryptedDateTime.php create mode 100644 tests/Casts/AsEncryptedDateTest.php create mode 100644 tests/Casts/AsEncryptedDateTimeTest.php create mode 100644 tests/Casts/AsEncryptedImmutableDateTest.php create mode 100644 tests/Casts/AsEncryptedImmutableDateTimeTest.php create mode 100644 tests/Casts/EncryptedDateTimeTest.php create mode 100644 tests/_mocks/DummyEncrypter.php create mode 100644 tests/_mocks/Model.php diff --git a/MIGRATING.md b/MIGRATING.md index 16c4286..09fe06d 100644 --- a/MIGRATING.md +++ b/MIGRATING.md @@ -3,7 +3,7 @@ ## To Laravel Encrypted Casting The main difference between this package and [Laravel Encrypted Casting](https://laravel.com/docs/eloquent-mutators#encrypted-casting) is that this package serializes the data before encrypting it, while Laravel Encrypted Casting encrypts the data directly. This means that the data is not compatible between the two packages. In order to migrate from this package to Laravel Encrypted Casting, you will need to decrypt the data and then re-encrypt it using Laravel Encrypted Casting. Here is a step-by-step guide on how to do this: -[//]: # (TODO: What to do when you need serialized data or encrypted dates?) +[//]: # (TODO: What to do when you need encrypted serialized data?) 1. Make sure you're running on Laravel 11 or higher. 2. Remove the `Swis\Laravel\Encrypted\EncryptedModel` from your models and replace it with `Illuminate\Database\Eloquent\Model`: @@ -23,7 +23,20 @@ The main difference between this package and [Laravel Encrypted Casting](https:/ + 'secret' => 'encrypted', + ]; ``` -4. Set up our custom model encrypter in your `AppServiceProvider`: +4. If you're using encrypted date(time)s, use the custom casts provided by this package: +```diff +- protected $encrypted = [ +- 'secret', +- ]; +- +- protected $casts = [ +- 'secret' => 'datetime', +- ]; ++ protected $casts = [ ++ 'secret' => \Swis\Laravel\Encrypted\Casts\AsEncryptedDateTime::class, ++ ]; +``` +5. Set up our custom model encrypter in your `AppServiceProvider`: ```php public function boot(): void { @@ -32,9 +45,9 @@ public function boot(): void // ... all your other models that used to extend \Swis\Laravel\Encrypted\EncryptedModel } ``` -5. Run our re-encryption command: +6. Run our re-encryption command: ```bash php artisan encrypted-data:re-encrypt:models --quietly --no-touch ``` N.B. Use `--help` to see all available options and modify as needed! -6. Remove our custom model encrypter from your `AppServiceProvider` (step 4). +7. Remove our custom model encrypter from your `AppServiceProvider` (step 5). diff --git a/README.md b/README.md index b2e6538..9146002 100644 --- a/README.md +++ b/README.md @@ -21,24 +21,26 @@ composer require swisnl/laravel-encrypted-data ## Usage -### Models +### Eloquent casts > [!WARNING] -> Laravel supports [encrypted casts](https://laravel.com/docs/10.x/eloquent-mutators#encrypted-casting) since version 8.12, so new projects should use that instead of the models provided by this package. -> -> Please see [MIGRATING](MIGRATING.md) for a step-by-step guide on how to migrate. +> Older versions of this package needed a custom model class to encrypt data. This is now deprecated in favor of custom casts. Please see [MIGRATING](MIGRATING.md) for a step-by-step guide on how to migrate. > -Extend `\Swis\Laravel\Encrypted\EncryptedModel` in your model and define the encrypted fields. Make sure your database columns are long enough, so your data isn't truncated! +You can use the Eloquent casts provided by this package and everything will be encrypted/decrypted under the hood! + +#### Datetime ```php -protected $encrypted = [ - 'secret', +protected $casts = [ + 'date' => \Swis\Laravel\Encrypted\Casts\AsEncryptedDate::class, + 'datetime' => \Swis\Laravel\Encrypted\Casts\AsEncryptedDateTime::class, + 'immutable_date' => \Swis\Laravel\Encrypted\Casts\AsEncryptedImmutableDate::class, + 'immutable_datetime' => \Swis\Laravel\Encrypted\Casts\AsEncryptedImmutableDateTime::class, + 'date_with_custom_format' => \Swis\Laravel\Encrypted\Casts\AsEncryptedDate::format('Y-m-d'), ]; ``` -You can now simply use the model properties as usual and everything will be encrypted/decrypted under the hood! - ### Filesystem Configure the storage driver in `config/filesystems.php`. @@ -60,10 +62,9 @@ Due to the encryption, some issues/limitations apply: 1. Encrypted data is — depending on what you encrypt — roughly 30-40% bigger. -### Models +### Casts -1. You can't query or order columns that are encrypted in your SQL-statements, but you can query or sort the results using collection methods; -2. All data is being serialized before it is encrypted — and unserialized after it is decrypted — so everything is stored exactly as how Laravel would insert it into the database. You can use [Eloquent Mutators](https://laravel.com/docs/9.x/eloquent-mutators) as you normally would. +1. You can't query or order columns that are encrypted in your SQL-statements, but you can query or sort the results using collection methods. ### Filesystem diff --git a/src/Casts/AsEncryptedDate.php b/src/Casts/AsEncryptedDate.php new file mode 100644 index 0000000..8cb8e6a --- /dev/null +++ b/src/Casts/AsEncryptedDate.php @@ -0,0 +1,29 @@ + $value?->startOfDay()); + } + + /** + * Specify the format to use when the model is serialized to an array or JSON. + */ + public static function format(string $format): string + { + return static::class.':'.$format; + } +} diff --git a/src/Casts/AsEncryptedDateTime.php b/src/Casts/AsEncryptedDateTime.php new file mode 100644 index 0000000..192df9e --- /dev/null +++ b/src/Casts/AsEncryptedDateTime.php @@ -0,0 +1,27 @@ + $value?->startOfDay()->toImmutable()); + } + + /** + * Specify the format to use when the model is serialized to an array or JSON. + */ + public static function format(string $format): string + { + return static::class.':'.$format; + } +} diff --git a/src/Casts/AsEncryptedImmutableDateTime.php b/src/Casts/AsEncryptedImmutableDateTime.php new file mode 100644 index 0000000..827dee1 --- /dev/null +++ b/src/Casts/AsEncryptedImmutableDateTime.php @@ -0,0 +1,29 @@ + $value?->toImmutable()); + } + + /** + * Specify the format to use when the model is serialized to an array or JSON. + */ + public static function format(string $format): string + { + return static::class.':'.$format; + } +} diff --git a/src/Casts/EncryptedDateTime.php b/src/Casts/EncryptedDateTime.php new file mode 100644 index 0000000..60b8eea --- /dev/null +++ b/src/Casts/EncryptedDateTime.php @@ -0,0 +1,123 @@ + + * + * @todo Implement \Illuminate\Contracts\Database\Eloquent\ComparesCastableAttributes when Laravel <12 support is dropped. + */ +class EncryptedDateTime implements CastsAttributes, SerializesCastableAttributes +{ + /** + * @param array $arguments + * @param \Closure(Carbon|CarbonImmutable|null): (Carbon|CarbonImmutable|null)|null $modifier + */ + public function __construct(protected array $arguments, protected ?\Closure $modifier = null) + { + } + + /** + * @param array $attributes + */ + public function get(Model $model, string $key, mixed $value, array $attributes): Carbon|CarbonImmutable|null + { + if ($value === null) { + return null; + } + + $value = ($model::$encrypter ?? Crypt::getFacadeRoot())->decrypt($value, false); + + return with($this->parse($model, $key, $value, $attributes), $this->modifier); + } + + /** + * @param \Illuminate\Support\Carbon|\Carbon\CarbonImmutable|string|null $value + * @param array $attributes + */ + public function set(Model $model, string $key, #[\SensitiveParameter] mixed $value, array $attributes): ?string + { + if ($value === null) { + return null; + } + + $value = is_string($value) ? $value : $value->format($model->getDateFormat()); + + return ($model::$encrypter ?? Crypt::getFacadeRoot())->encrypt($value, false); + } + + /** + * @param string|null $value + * @param array $attributes + */ + public function serialize(Model $model, string $key, #[\SensitiveParameter] mixed $value, array $attributes): ?string + { + if ($value === null) { + return null; + } + + return !empty($this->arguments[0]) ? Date::parse($value)->format($this->arguments[0]) : $value; + } + + /** + * @param string|null $firstValue + * @param string|null $secondValue + */ + public function compare(Model $model, string $key, mixed $firstValue, mixed $secondValue): bool + { + if (!empty(($model::$encrypter ?? Crypt::getFacadeRoot())->getPreviousKeys())) { + return false; + } + + $firstValue = $this->get($model, $key, $firstValue, []); + $secondValue = $this->get($model, $key, $secondValue, []); + + if ($firstValue === $secondValue) { + return true; + } + + if ($firstValue === null || $secondValue === null) { + return false; + } + + return $firstValue->equalTo($secondValue); + } + + /** + * @param string|int $value + * @param array $attributes + */ + protected function parse(Model $model, string $key, #[\SensitiveParameter] mixed $value, array $attributes): Carbon|CarbonImmutable|null + { + if (is_numeric($value)) { + return Date::createFromTimestamp($value, date_default_timezone_get()); + } + + if ($this->isStandardDateFormat($value)) { + return Date::instance(Carbon::createFromFormat('Y-m-d', $value)->startOfDay()); + } + + try { + return Date::createFromFormat($model->getDateFormat(), $value); + } catch (\InvalidArgumentException) { + return Date::parse($value); + } + } + + protected function isStandardDateFormat(#[\SensitiveParameter] string $value): bool + { + return (bool) preg_match('/^(\d{4})-(\d{1,2})-(\d{1,2})$/', $value); + } +} diff --git a/src/Commands/ReEncryptModels.php b/src/Commands/ReEncryptModels.php index 606fe3d..df8edbb 100644 --- a/src/Commands/ReEncryptModels.php +++ b/src/Commands/ReEncryptModels.php @@ -1,5 +1,7 @@ get($model, 'date', null, []); + + $this->assertNull($result); + } + + public function testGetAppliesStartOfDayClosure(): void + { + $cast = AsEncryptedDate::castUsing([]); + $model = new Model(); + $date = '2024-07-02 15:30:45'; + + $result = $cast->get($model, 'date', $date, []); + + $this->assertInstanceOf(Carbon::class, $result); + $this->assertEquals('2024-07-02 00:00:00', $result->toDateTimeString()); + } + + public function testFormatReturnsExpectedString(): void + { + $this->assertEquals(AsEncryptedDate::class.':Y-m-d', AsEncryptedDate::format('Y-m-d')); + } +} diff --git a/tests/Casts/AsEncryptedDateTimeTest.php b/tests/Casts/AsEncryptedDateTimeTest.php new file mode 100644 index 0000000..f522c30 --- /dev/null +++ b/tests/Casts/AsEncryptedDateTimeTest.php @@ -0,0 +1,45 @@ +get($model, 'datetime', null, []); + + $this->assertNull($result); + } + + public function testGetReturnsCarbonInstance(): void + { + $cast = AsEncryptedDateTime::castUsing([]); + $model = new Model(); + $date = '2024-07-02 15:30:45'; + + $result = $cast->get($model, 'datetime', $date, []); + + $this->assertInstanceOf(Carbon::class, $result); + $this->assertEquals('2024-07-02 15:30:45', $result->toDateTimeString()); + } + + public function testFormatReturnsExpectedString(): void + { + $this->assertEquals(AsEncryptedDateTime::class.':Y-m-d H:i:s', AsEncryptedDateTime::format('Y-m-d H:i:s')); + } +} diff --git a/tests/Casts/AsEncryptedImmutableDateTest.php b/tests/Casts/AsEncryptedImmutableDateTest.php new file mode 100644 index 0000000..cd61217 --- /dev/null +++ b/tests/Casts/AsEncryptedImmutableDateTest.php @@ -0,0 +1,45 @@ +get($model, 'date', null, []); + + $this->assertNull($result); + } + + public function testGetAppliesStartOfDayAndImmutable(): void + { + $cast = AsEncryptedImmutableDate::castUsing([]); + $model = new Model(); + $date = '2024-07-02 15:30:45'; + + $result = $cast->get($model, 'date', $date, []); + + $this->assertInstanceOf(CarbonImmutable::class, $result); + $this->assertEquals('2024-07-02 00:00:00', $result->toDateTimeString()); + } + + public function testFormatReturnsExpectedString(): void + { + $this->assertEquals(AsEncryptedImmutableDate::class.':Y-m-d', AsEncryptedImmutableDate::format('Y-m-d')); + } +} diff --git a/tests/Casts/AsEncryptedImmutableDateTimeTest.php b/tests/Casts/AsEncryptedImmutableDateTimeTest.php new file mode 100644 index 0000000..588cd1b --- /dev/null +++ b/tests/Casts/AsEncryptedImmutableDateTimeTest.php @@ -0,0 +1,45 @@ +get($model, 'datetime', null, []); + + $this->assertNull($result); + } + + public function testGetReturnsCarbonImmutableInstance(): void + { + $cast = AsEncryptedImmutableDateTime::castUsing([]); + $model = new Model(); + $date = '2024-07-02 15:30:45'; + + $result = $cast->get($model, 'datetime', $date, []); + + $this->assertInstanceOf(CarbonImmutable::class, $result); + $this->assertEquals('2024-07-02 15:30:45', $result->toDateTimeString()); + } + + public function testFormatReturnsExpectedString(): void + { + $this->assertEquals(AsEncryptedImmutableDateTime::class.':Y-m-d H:i:s', AsEncryptedImmutableDateTime::format('Y-m-d H:i:s')); + } +} diff --git a/tests/Casts/EncryptedDateTimeTest.php b/tests/Casts/EncryptedDateTimeTest.php new file mode 100644 index 0000000..88f549c --- /dev/null +++ b/tests/Casts/EncryptedDateTimeTest.php @@ -0,0 +1,232 @@ +get($model, 'date', null, []); + + $this->assertNull($result); + } + + public function testGetParsesNumericTimestamp(): void + { + $cast = new EncryptedDateTime([]); + $model = new Model(); + $date = 1719923640; // 2024-07-02 12:34:00 UTC + + $result = $cast->get($model, 'date', $date, []); + + $this->assertInstanceOf(Carbon::class, $result); + $this->assertEquals('2024-07-02 12:34:00', $result->toDateTimeString()); + } + + public function testGetParsesStandardDateFormat(): void + { + $cast = new EncryptedDateTime([]); + $model = new Model(); + $date = '2024-07-02'; + + $result = $cast->get($model, 'date', $date, []); + + $this->assertInstanceOf(Carbon::class, $result); + $this->assertEquals('2024-07-02 00:00:00', $result->toDateTimeString()); + } + + public function testGetParsesCustomFormat(): void + { + $cast = new EncryptedDateTime([]); + $model = new Model(); + $model->setDateFormat('d/m/y H:i:s'); + $date = '02/07/24 15:30:00'; + + $result = $cast->get($model, 'date', $date, []); + + $this->assertInstanceOf(Carbon::class, $result); + $this->assertEquals('2024-07-02 15:30:00', $result->toDateTimeString()); + } + + public function testGetFallbacksToParseOnInvalidFormat(): void + { + $cast = new EncryptedDateTime([]); + $model = new Model(); + $date = 'July 2, 2024 8:00pm'; + + $result = $cast->get($model, 'date', $date, []); + + $this->assertInstanceOf(Carbon::class, $result); + $this->assertEquals('2024-07-02 20:00:00', $result->toDateTimeString()); + } + + public function testGetAppliesModifierIfSet(): void + { + $cast = new EncryptedDateTime([], fn ($value) => $value->addDay()); + $model = new Model(); + $date = '2024-07-02 00:00:00'; + + $result = $cast->get($model, 'date', $date, []); + + $this->assertEquals('2024-07-03 00:00:00', $result->toDateTimeString()); + } + + public function testGetReturnsImmutableWhenDateFacadeConfigured(): void + { + Date::use(CarbonImmutable::class); + $cast = new EncryptedDateTime([]); + $model = new Model(); + $date = '2024-07-02 12:34:00'; + + $result = $cast->get($model, 'date', $date, []); + + $this->assertInstanceOf(CarbonImmutable::class, $result); + $this->assertEquals('2024-07-02 12:34:00', $result->toDateTimeString()); + + // Reset Date facade to default + Date::use(DateFactory::DEFAULT_CLASS_NAME); + } + + public function testSetReturnsNullWhenValueIsNull(): void + { + $cast = new EncryptedDateTime([]); + $model = new Model(); + + $result = $cast->set($model, 'date', null, []); + + $this->assertNull($result); + } + + public function testSetEncryptsCarbonInstance(): void + { + $cast = new EncryptedDateTime([]); + $model = new Model(); + $date = Carbon::create(2024, 7, 2, 12, 0, 0); + + $result = $cast->set($model, 'date', $date, []); + + $this->assertEquals('2024-07-02 12:00:00', $result); + } + + public function testSetEncryptsString(): void + { + $cast = new EncryptedDateTime([]); + $model = new Model(); + $date = '2024-07-02 12:00:00'; + + $result = $cast->set($model, 'date', $date, []); + + $this->assertEquals($date, $result); + } + + public function testSerializeReturnsNullWhenValueIsNull(): void + { + $cast = new EncryptedDateTime([]); + $model = new Model(); + + $result = $cast->serialize($model, 'date', null, []); + + $this->assertNull($result); + } + + public function testSerializeFormatsValueUsingTheDefaultFormat(): void + { + $cast = new EncryptedDateTime([]); + $model = new Model(); + $date = Carbon::create(2024, 7, 2, 12, 0, 0)->toJSON(); + + $result = $cast->serialize($model, 'date', $date, []); + + $this->assertEquals('2024-07-02T12:00:00.000000Z', $result); + } + + public function testSerializeFormatsValueUsingTheProvidedFormat(): void + { + $cast = new EncryptedDateTime(['Y-m-d']); + $model = new Model(); + $date = Carbon::create(2024, 7, 2, 12, 0, 0)->toJSON(); + + $result = $cast->serialize($model, 'date', $date, []); + + $this->assertEquals('2024-07-02', $result); + } + + public function testCompareReturnsTrueForEqualDates(): void + { + $cast = new EncryptedDateTime([]); + $model = new Model(); + + $date = '2024-07-02 12:00:00'; + $encrypted = $model::$encrypter->encrypt($date, false); + + $this->assertTrue($cast->compare($model, 'date', $encrypted, $encrypted)); + } + + public function testCompareReturnsFalseForDifferentDates(): void + { + $cast = new EncryptedDateTime([]); + $model = new Model(); + + $date1 = '2024-07-02 12:00:00'; + $date2 = '2024-07-03 12:00:00'; + $encrypted1 = $model::$encrypter->encrypt($date1, false); + $encrypted2 = $model::$encrypter->encrypt($date2, false); + + $this->assertFalse($cast->compare($model, 'date', $encrypted1, $encrypted2)); + } + + public function testCompareReturnsFalseIfOriginalIsNull(): void + { + $cast = new EncryptedDateTime([]); + $model = new Model(); + + $date = '2024-07-02 12:00:00'; + $encrypted = $model::$encrypter->encrypt($date, false); + + $this->assertFalse($cast->compare($model, 'date', null, $encrypted)); + } + + public function testCompareReturnsFalseIfValueIsNull(): void + { + $cast = new EncryptedDateTime([]); + $model = new Model(); + + $date = '2024-07-02 12:00:00'; + $encrypted = $model::$encrypter->encrypt($date, false); + + $this->assertFalse($cast->compare($model, 'date', $encrypted, null)); + } + + public function testCompareReturnsFalseIfPreviousKeysExist(): void + { + $cast = new EncryptedDateTime([]); + $model = new Model(); + + // Simulate previous keys + /* @noinspection PhpUndefinedMethodInspection */ + $model::$encrypter->setPreviousKeys(['dummy-key']); + + $date = '2024-07-02 12:00:00'; + $encrypted = $model::$encrypter->encrypt($date, false); + + $this->assertFalse($cast->compare($model, 'date', $encrypted, $encrypted)); + } +} diff --git a/tests/_mocks/DummyEncrypter.php b/tests/_mocks/DummyEncrypter.php new file mode 100644 index 0000000..8621c57 --- /dev/null +++ b/tests/_mocks/DummyEncrypter.php @@ -0,0 +1,30 @@ +previousKeys; + } + + public function setPreviousKeys(array $previousKeys): void + { + $this->previousKeys = $previousKeys; + } +} diff --git a/tests/_mocks/Model.php b/tests/_mocks/Model.php new file mode 100644 index 0000000..85e0caa --- /dev/null +++ b/tests/_mocks/Model.php @@ -0,0 +1,8 @@ + Date: Sun, 15 Jun 2025 12:17:51 +0200 Subject: [PATCH 3/9] feat: add support for encrypted boolean --- MIGRATING.md | 13 ++-- README.md | 8 +++ src/Casts/AsEncryptedBoolean.php | 65 +++++++++++++++++ tests/Casts/AsEncryptedBooleanTest.php | 97 ++++++++++++++++++++++++++ 4 files changed, 177 insertions(+), 6 deletions(-) create mode 100644 src/Casts/AsEncryptedBoolean.php create mode 100644 tests/Casts/AsEncryptedBooleanTest.php diff --git a/MIGRATING.md b/MIGRATING.md index 09fe06d..1dfacf0 100644 --- a/MIGRATING.md +++ b/MIGRATING.md @@ -3,8 +3,6 @@ ## To Laravel Encrypted Casting The main difference between this package and [Laravel Encrypted Casting](https://laravel.com/docs/eloquent-mutators#encrypted-casting) is that this package serializes the data before encrypting it, while Laravel Encrypted Casting encrypts the data directly. This means that the data is not compatible between the two packages. In order to migrate from this package to Laravel Encrypted Casting, you will need to decrypt the data and then re-encrypt it using Laravel Encrypted Casting. Here is a step-by-step guide on how to do this: -[//]: # (TODO: What to do when you need encrypted serialized data?) - 1. Make sure you're running on Laravel 11 or higher. 2. Remove the `Swis\Laravel\Encrypted\EncryptedModel` from your models and replace it with `Illuminate\Database\Eloquent\Model`: ```diff @@ -23,17 +21,20 @@ The main difference between this package and [Laravel Encrypted Casting](https:/ + 'secret' => 'encrypted', + ]; ``` -4. If you're using encrypted date(time)s, use the custom casts provided by this package: +4. If you're using encrypted booleans or date(time)s, use the custom casts provided by this package: ```diff - protected $encrypted = [ -- 'secret', +- 'secret_boolean', +- 'secret_datetime', - ]; - - protected $casts = [ -- 'secret' => 'datetime', +- 'secret_boolean' => 'bool', +- 'secret_datetime' => 'datetime', - ]; + protected $casts = [ -+ 'secret' => \Swis\Laravel\Encrypted\Casts\AsEncryptedDateTime::class, ++ 'secret_boolean' => \Swis\Laravel\Encrypted\Casts\AsEncryptedBoolean::class, ++ 'secret_datetime' => \Swis\Laravel\Encrypted\Casts\AsEncryptedDateTime::class, + ]; ``` 5. Set up our custom model encrypter in your `AppServiceProvider`: diff --git a/README.md b/README.md index 9146002..2de1cac 100644 --- a/README.md +++ b/README.md @@ -29,6 +29,14 @@ composer require swisnl/laravel-encrypted-data You can use the Eloquent casts provided by this package and everything will be encrypted/decrypted under the hood! +#### Boolean + +```php +protected $casts = [ + 'boolean' => \Swis\Laravel\Encrypted\Casts\AsEncryptedBoolean::class, +]; +``` + #### Datetime ```php diff --git a/src/Casts/AsEncryptedBoolean.php b/src/Casts/AsEncryptedBoolean.php new file mode 100644 index 0000000..41b06be --- /dev/null +++ b/src/Casts/AsEncryptedBoolean.php @@ -0,0 +1,65 @@ + + */ + public static function castUsing(array $arguments): CastsAttributes + { + /* + * @todo Implement \Illuminate\Contracts\Database\Eloquent\ComparesCastableAttributes when Laravel <12 support is dropped. + */ + return new class implements CastsAttributes { + /** + * @param string|null $value + * @param array $attributes + */ + public function get(Model $model, string $key, mixed $value, array $attributes): ?bool + { + if ($value === null) { + return null; + } + + return (bool) ($model::$encrypter ?? Crypt::getFacadeRoot())->decrypt($value, false); + } + + /** + * @param bool|null $value + * @param array $attributes + */ + public function set(Model $model, string $key, #[\SensitiveParameter] mixed $value, array $attributes): ?string + { + if ($value === null) { + return null; + } + + return ($model::$encrypter ?? Crypt::getFacadeRoot())->encrypt((string) (int) $value, false); + } + + /** + * @param string|null $firstValue + * @param string|null $secondValue + */ + public function compare(Model $model, string $key, mixed $firstValue, mixed $secondValue): bool + { + if (!empty(($model::$encrypter ?? Crypt::getFacadeRoot())->getPreviousKeys())) { + return false; + } + + return $this->get($model, $key, $firstValue, []) === $this->get($model, $key, $secondValue, []); + } + }; + } +} diff --git a/tests/Casts/AsEncryptedBooleanTest.php b/tests/Casts/AsEncryptedBooleanTest.php new file mode 100644 index 0000000..64876d9 --- /dev/null +++ b/tests/Casts/AsEncryptedBooleanTest.php @@ -0,0 +1,97 @@ +get($model, 'flag', null, []); + + $this->assertNull($result); + } + + public function testGetReturnsBoolean(): void + { + $cast = AsEncryptedBoolean::castUsing([]); + $model = new Model(); + + $encryptedTrue = Model::$encrypter->encrypt('1', false); + $encryptedFalse = Model::$encrypter->encrypt('0', false); + + $this->assertTrue($cast->get($model, 'flag', $encryptedTrue, [])); + $this->assertFalse($cast->get($model, 'flag', $encryptedFalse, [])); + } + + public function testSetReturnsNullForNull(): void + { + $cast = AsEncryptedBoolean::castUsing([]); + $model = new Model(); + + $result = $cast->set($model, 'flag', null, []); + + $this->assertNull($result); + } + + public function testSetReturnsEncryptedString(): void + { + $cast = AsEncryptedBoolean::castUsing([]); + $model = new Model(); + + $encrypted = $cast->set($model, 'flag', true, []); + $this->assertEquals(Model::$encrypter->encrypt('1', false), $encrypted); + + $encrypted = $cast->set($model, 'flag', false, []); + $this->assertEquals(Model::$encrypter->encrypt('0', false), $encrypted); + } + + public function testCompareReturnsTrueForSameDecryptedValue(): void + { + $cast = AsEncryptedBoolean::castUsing([]); + $model = new Model(); + + $encryptedTrue = Model::$encrypter->encrypt('1', false); + $encryptedTrue2 = Model::$encrypter->encrypt('1', false); + + $this->assertTrue($cast->compare($model, 'flag', $encryptedTrue, $encryptedTrue2)); + } + + public function testCompareReturnsFalseForDifferentDecryptedValue(): void + { + $cast = AsEncryptedBoolean::castUsing([]); + $model = new Model(); + + $encryptedTrue = Model::$encrypter->encrypt('1', false); + $encryptedFalse = Model::$encrypter->encrypt('0', false); + + $this->assertFalse($cast->compare($model, 'flag', $encryptedTrue, $encryptedFalse)); + } + + public function testCompareReturnsFalseIfPreviousKeysExist(): void + { + $cast = AsEncryptedBoolean::castUsing([]); + $model = new Model(); + + // Simulate previous keys + /* @noinspection PhpUndefinedMethodInspection */ + $model::$encrypter->setPreviousKeys(['dummy-key']); + + $encrypted = Model::$encrypter->encrypt('1', false); + + $this->assertFalse($cast->compare($model, 'flag', $encrypted, $encrypted)); + } +} From 3e7a62e64be94774cbde853c73cd53b2e1c7eb20 Mon Sep 17 00:00:00 2001 From: Jasper Zonneveld Date: Sun, 15 Jun 2025 13:48:57 +0200 Subject: [PATCH 4/9] docs: expand migration guide --- MIGRATING.md | 8 +++++--- 1 file changed, 5 insertions(+), 3 deletions(-) diff --git a/MIGRATING.md b/MIGRATING.md index 1dfacf0..8823d4c 100644 --- a/MIGRATING.md +++ b/MIGRATING.md @@ -37,7 +37,8 @@ The main difference between this package and [Laravel Encrypted Casting](https:/ + 'secret_datetime' => \Swis\Laravel\Encrypted\Casts\AsEncryptedDateTime::class, + ]; ``` -5. Set up our custom model encrypter in your `AppServiceProvider`: +5. If you're using other casts for encrypted attributes, or you need serialization support, you should create custom casts yourself, as this package does not provide casts for every situation. Please see [Custom Casts](https://laravel.com/docs/eloquent-mutators#custom-casts) for more information on how to create custom casts. You can use any of the casts provided by this package as a reference. +6. Set up our custom model encrypter in your `AppServiceProvider`: ```php public function boot(): void { @@ -46,9 +47,10 @@ public function boot(): void // ... all your other models that used to extend \Swis\Laravel\Encrypted\EncryptedModel } ``` -6. Run our re-encryption command: +This custom model encrypter is backward compatible with the old `EncryptedModel` and will handle the deserialization of the data before casts kick in. Data will **not** be serialized when re-encrypting, so it will be compatible with Laravel Encrypted Casting. This makes sure your application can keep running and the data is not lost during the migration process. +7. Run our re-encryption command: ```bash php artisan encrypted-data:re-encrypt:models --quietly --no-touch ``` N.B. Use `--help` to see all available options and modify as needed! -7. Remove our custom model encrypter from your `AppServiceProvider` (step 5). +8. Remove our custom model encrypter from your `AppServiceProvider` (step 6). From 47fd69a68ba597b80384e1d69da6ba621672b6f4 Mon Sep 17 00:00:00 2001 From: Jasper Zonneveld Date: Sun, 15 Jun 2025 13:55:45 +0200 Subject: [PATCH 5/9] fixup! feat: add support for encrypted date(time) --- src/Casts/EncryptedDateTime.php | 5 ++--- 1 file changed, 2 insertions(+), 3 deletions(-) diff --git a/src/Casts/EncryptedDateTime.php b/src/Casts/EncryptedDateTime.php index 60b8eea..6aa650f 100644 --- a/src/Casts/EncryptedDateTime.php +++ b/src/Casts/EncryptedDateTime.php @@ -6,6 +6,7 @@ use Carbon\CarbonImmutable; use Illuminate\Contracts\Database\Eloquent\CastsAttributes; +use Illuminate\Contracts\Database\Eloquent\ComparesCastableAttributes; use Illuminate\Contracts\Database\Eloquent\SerializesCastableAttributes; use Illuminate\Database\Eloquent\Model; use Illuminate\Support\Carbon; @@ -16,10 +17,8 @@ * @internal * * @implements \Illuminate\Contracts\Database\Eloquent\CastsAttributes<\Illuminate\Support\Carbon|\Carbon\CarbonImmutable, string|\Illuminate\Support\Carbon|\Carbon\CarbonImmutable> - * - * @todo Implement \Illuminate\Contracts\Database\Eloquent\ComparesCastableAttributes when Laravel <12 support is dropped. */ -class EncryptedDateTime implements CastsAttributes, SerializesCastableAttributes +class EncryptedDateTime implements CastsAttributes, SerializesCastableAttributes, ComparesCastableAttributes { /** * @param array $arguments From 266ef483a966b92351c4badb49528f5faa1b7f63 Mon Sep 17 00:00:00 2001 From: Jasper Zonneveld Date: Sun, 15 Jun 2025 13:55:55 +0200 Subject: [PATCH 6/9] fixup! feat: add support for encrypted boolean --- src/Casts/AsEncryptedBoolean.php | 6 ++---- 1 file changed, 2 insertions(+), 4 deletions(-) diff --git a/src/Casts/AsEncryptedBoolean.php b/src/Casts/AsEncryptedBoolean.php index 41b06be..3cc89bb 100644 --- a/src/Casts/AsEncryptedBoolean.php +++ b/src/Casts/AsEncryptedBoolean.php @@ -6,6 +6,7 @@ use Illuminate\Contracts\Database\Eloquent\Castable; use Illuminate\Contracts\Database\Eloquent\CastsAttributes; +use Illuminate\Contracts\Database\Eloquent\ComparesCastableAttributes; use Illuminate\Database\Eloquent\Model; use Illuminate\Support\Facades\Crypt; @@ -18,10 +19,7 @@ class AsEncryptedBoolean implements Castable */ public static function castUsing(array $arguments): CastsAttributes { - /* - * @todo Implement \Illuminate\Contracts\Database\Eloquent\ComparesCastableAttributes when Laravel <12 support is dropped. - */ - return new class implements CastsAttributes { + return new class implements CastsAttributes, ComparesCastableAttributes { /** * @param string|null $value * @param array $attributes From edfb0d7d11748cf0036fa394cf603c274bd195ee Mon Sep 17 00:00:00 2001 From: Jasper Zonneveld Date: Sun, 15 Jun 2025 13:56:29 +0200 Subject: [PATCH 7/9] build: drop Laravel <12.18 support --- .github/workflows/tests.yml | 9 ++------- MIGRATING.md | 2 +- composer.json | 6 +++--- tests/ModelEncrypterTest.php | 9 --------- 4 files changed, 6 insertions(+), 20 deletions(-) diff --git a/.github/workflows/tests.yml b/.github/workflows/tests.yml index 2ef8982..ff75cc6 100644 --- a/.github/workflows/tests.yml +++ b/.github/workflows/tests.yml @@ -9,14 +9,9 @@ jobs: strategy: fail-fast: false matrix: - php: [ '8.1', '8.2', '8.3' ] - laravel: [ '10.*', '11.*', '12.*' ] + php: [ '8.2', '8.3' ] + laravel: [ '^12.18' ] stability: [ prefer-lowest, prefer-stable ] - exclude: - - laravel: '11.*' - php: '8.1' - - laravel: '12.*' - php: '8.1' name: P${{ matrix.php }} - L${{ matrix.laravel }} - ${{ matrix.stability }} diff --git a/MIGRATING.md b/MIGRATING.md index 8823d4c..c679ae7 100644 --- a/MIGRATING.md +++ b/MIGRATING.md @@ -3,7 +3,7 @@ ## To Laravel Encrypted Casting The main difference between this package and [Laravel Encrypted Casting](https://laravel.com/docs/eloquent-mutators#encrypted-casting) is that this package serializes the data before encrypting it, while Laravel Encrypted Casting encrypts the data directly. This means that the data is not compatible between the two packages. In order to migrate from this package to Laravel Encrypted Casting, you will need to decrypt the data and then re-encrypt it using Laravel Encrypted Casting. Here is a step-by-step guide on how to do this: -1. Make sure you're running on Laravel 11 or higher. +1. Make sure you're running on Laravel 12.18 or higher. 2. Remove the `Swis\Laravel\Encrypted\EncryptedModel` from your models and replace it with `Illuminate\Database\Eloquent\Model`: ```diff - use Swis\Laravel\Encrypted\EncryptedModel diff --git a/composer.json b/composer.json index 46eaabe..e19fdb6 100644 --- a/composer.json +++ b/composer.json @@ -21,13 +21,13 @@ ], "require": { "php": "^8.1", - "laravel/framework": "^10.0|^11.0|^12.0", + "laravel/framework": "^12.18", "swisnl/flysystem-encrypted": "^2.0" }, "require-dev": { "friendsofphp/php-cs-fixer": "^3.0", - "orchestra/testbench": "^8.0|^9.0|^10.0", - "phpunit/phpunit": "^10.5|^11.5" + "orchestra/testbench": "^10.0", + "phpunit/phpunit": "^11.5" }, "autoload": { "psr-4": { diff --git a/tests/ModelEncrypterTest.php b/tests/ModelEncrypterTest.php index 5547bc8..f209734 100644 --- a/tests/ModelEncrypterTest.php +++ b/tests/ModelEncrypterTest.php @@ -3,7 +3,6 @@ namespace Swis\Laravel\Encrypted\Tests; use Illuminate\Contracts\Encryption\Encrypter as EncrypterContract; -use Illuminate\Foundation\Application; use PHPUnit\Framework\MockObject\MockObject; use PHPUnit\Framework\TestCase; use Swis\Laravel\Encrypted\ModelEncrypter; @@ -92,10 +91,6 @@ public function testGetKeyDelegates(): void public function testGetPreviousKeysMergesWithDummy(): void { - if (version_compare(Application::VERSION, '11.0.0', '<')) { - $this->markTestSkipped('The test requires Laravel 11 or higher to run.'); - } - $this->mockEncrypter->expects($this->once()) ->method('getPreviousKeys') ->willReturn(['prev1', 'prev2']); @@ -110,10 +105,6 @@ public function testGetPreviousKeysMergesWithDummy(): void public function testGetAllKeysMergesCurrentAndPrevious(): void { - if (version_compare(Application::VERSION, '11.0.0', '<')) { - $this->markTestSkipped('The test requires Laravel 11 or higher to run.'); - } - $this->mockEncrypter->expects($this->once()) ->method('getKey') ->willReturn('key123'); From cf3ef7c9ff82f21346637ae448346acdedbfcf01 Mon Sep 17 00:00:00 2001 From: Jasper Zonneveld Date: Sun, 22 Jun 2025 12:04:02 +0200 Subject: [PATCH 8/9] test: move tests to Unit sub namespace --- tests/{ => Unit}/Casts/AsEncryptedBooleanTest.php | 6 +++--- tests/{ => Unit}/Casts/AsEncryptedDateTest.php | 6 +++--- tests/{ => Unit}/Casts/AsEncryptedDateTimeTest.php | 6 +++--- tests/{ => Unit}/Casts/AsEncryptedImmutableDateTest.php | 6 +++--- tests/{ => Unit}/Casts/AsEncryptedImmutableDateTimeTest.php | 6 +++--- tests/{ => Unit}/Casts/EncryptedDateTimeTest.php | 6 +++--- tests/{ => Unit}/EncryptedModelTest.php | 5 +++-- tests/{ => Unit}/FilesystemTest.php | 3 ++- tests/{ => Unit}/ModelEncrypterTest.php | 2 +- tests/{ => Unit}/_mocks/Builder.php | 2 +- tests/{ => Unit}/_mocks/DummyEncrypter.php | 2 +- tests/{ => Unit}/_mocks/Model.php | 2 +- 12 files changed, 27 insertions(+), 25 deletions(-) rename tests/{ => Unit}/Casts/AsEncryptedBooleanTest.php (94%) rename tests/{ => Unit}/Casts/AsEncryptedDateTest.php (86%) rename tests/{ => Unit}/Casts/AsEncryptedDateTimeTest.php (87%) rename tests/{ => Unit}/Casts/AsEncryptedImmutableDateTest.php (87%) rename tests/{ => Unit}/Casts/AsEncryptedImmutableDateTimeTest.php (87%) rename tests/{ => Unit}/Casts/EncryptedDateTimeTest.php (97%) rename tests/{ => Unit}/EncryptedModelTest.php (96%) rename tests/{ => Unit}/FilesystemTest.php (78%) rename tests/{ => Unit}/ModelEncrypterTest.php (98%) rename tests/{ => Unit}/_mocks/Builder.php (88%) rename tests/{ => Unit}/_mocks/DummyEncrypter.php (91%) rename tests/{ => Unit}/_mocks/Model.php (68%) diff --git a/tests/Casts/AsEncryptedBooleanTest.php b/tests/Unit/Casts/AsEncryptedBooleanTest.php similarity index 94% rename from tests/Casts/AsEncryptedBooleanTest.php rename to tests/Unit/Casts/AsEncryptedBooleanTest.php index 64876d9..fc55833 100644 --- a/tests/Casts/AsEncryptedBooleanTest.php +++ b/tests/Unit/Casts/AsEncryptedBooleanTest.php @@ -1,11 +1,11 @@ Date: Tue, 24 Jun 2025 21:07:32 +0200 Subject: [PATCH 9/9] test: add tests for ReEncryptModels command --- .gitattributes | 1 + composer.json | 12 +- phpunit.xml.dist | 7 +- testbench.yaml | 20 ++ tests/Feature/ReEncryptModelsTest.php | 247 ++++++++++++++++++ workbench/app/Models/AbstractModel.php | 15 ++ workbench/app/Models/PlainModel.php | 17 ++ workbench/app/Models/SecretModel.php | 34 +++ workbench/app/Models/SomeClass.php | 8 + workbench/app/Models/SomeEnum.php | 8 + workbench/app/Models/SomeInterface.php | 8 + workbench/app/Models/SomeTrait.php | 8 + workbench/bootstrap/cache/.gitignore | 2 + workbench/composer.json | 12 + ...6_22_115501_create_secret_models_table.php | 35 +++ workbench/storage/app/.gitignore | 0 workbench/storage/app/public/.gitignore | 0 workbench/storage/framework/.gitignore | 0 workbench/storage/framework/cache/.gitignore | 0 workbench/storage/framework/data/.gitignore | 0 .../storage/framework/sessions/.gitignore | 0 .../storage/framework/testing/.gitignore | 0 workbench/storage/framework/views/.gitignore | 0 workbench/storage/logs/.gitignore | 0 24 files changed, 430 insertions(+), 4 deletions(-) create mode 100644 testbench.yaml create mode 100644 tests/Feature/ReEncryptModelsTest.php create mode 100644 workbench/app/Models/AbstractModel.php create mode 100644 workbench/app/Models/PlainModel.php create mode 100644 workbench/app/Models/SecretModel.php create mode 100644 workbench/app/Models/SomeClass.php create mode 100644 workbench/app/Models/SomeEnum.php create mode 100644 workbench/app/Models/SomeInterface.php create mode 100644 workbench/app/Models/SomeTrait.php create mode 100644 workbench/bootstrap/cache/.gitignore create mode 100644 workbench/composer.json create mode 100644 workbench/database/migrations/2025_06_22_115501_create_secret_models_table.php create mode 100644 workbench/storage/app/.gitignore create mode 100644 workbench/storage/app/public/.gitignore create mode 100644 workbench/storage/framework/.gitignore create mode 100644 workbench/storage/framework/cache/.gitignore create mode 100644 workbench/storage/framework/data/.gitignore create mode 100644 workbench/storage/framework/sessions/.gitignore create mode 100644 workbench/storage/framework/testing/.gitignore create mode 100644 workbench/storage/framework/views/.gitignore create mode 100644 workbench/storage/logs/.gitignore diff --git a/.gitattributes b/.gitattributes index 4898fd2..41c7848 100644 --- a/.gitattributes +++ b/.gitattributes @@ -10,5 +10,6 @@ /PULL_REQUEST_TEMPLATE.md export-ignore /ISSUE_TEMPLATE.md export-ignore /phpunit.xml.dist export-ignore +/workbench export-ignore /tests export-ignore /docs export-ignore diff --git a/composer.json b/composer.json index e19fdb6..e3ee508 100644 --- a/composer.json +++ b/composer.json @@ -36,13 +36,21 @@ }, "autoload-dev": { "psr-4": { - "Swis\\Laravel\\Encrypted\\Tests\\": "tests" + "Swis\\Laravel\\Encrypted\\Tests\\": "tests", + "Workbench\\App\\": "workbench/app/" } }, "scripts": { "test": "phpunit", "check-style": "php-cs-fixer fix --dry-run -v", - "fix-style": "php-cs-fixer fix" + "fix-style": "php-cs-fixer fix", + "post-autoload-dump": [ + "@clear", + "@prepare" + ], + "clear": "@php vendor/bin/testbench package:purge-skeleton --ansi", + "prepare": "@php vendor/bin/testbench package:discover --ansi", + "build": "@php vendor/bin/testbench workbench:build --ansi" }, "extra": { "branch-alias": { diff --git a/phpunit.xml.dist b/phpunit.xml.dist index 150d63f..2c66b40 100644 --- a/phpunit.xml.dist +++ b/phpunit.xml.dist @@ -9,8 +9,11 @@ stopOnFailure="false" cacheDirectory=".phpunit.cache"> - - tests + + ./tests/Feature + + + ./tests/Unit diff --git a/testbench.yaml b/testbench.yaml new file mode 100644 index 0000000..69ac364 --- /dev/null +++ b/testbench.yaml @@ -0,0 +1,20 @@ +laravel: './workbench' + +migrations: + - workbench/database/migrations + +workbench: + start: '/' + install: true + health: false + discovers: + web: false + api: false + commands: fa;se + components: false + factories: false + views: false + build: + - create-sqlite-db + - db-wipe + - migrate-fresh diff --git a/tests/Feature/ReEncryptModelsTest.php b/tests/Feature/ReEncryptModelsTest.php new file mode 100644 index 0000000..a2e23b8 --- /dev/null +++ b/tests/Feature/ReEncryptModelsTest.php @@ -0,0 +1,247 @@ +set('app.previous_keys', [Str::random(32)]); + } + + public function testCommandRequiresPreviousKey(): void + { + $this->artisan('encrypted-data:re-encrypt:models') + ->expectsOutput('Not all models can be re-encrypted because a previous key has not been set up. Please set APP_PREVIOUS_KEYS first!') + ->assertExitCode(1); + } + + public function testFailsWhenPathDoesNotExist(): void + { + $this->expectException(\InvalidArgumentException::class); + $this->expectExceptionMessage('The path '.base_path('foo').' is not a directory.'); + + $this->artisan('encrypted-data:re-encrypt:models', ['--path' => 'foo']); + } + + public function testFailsWhenNoModelsFounds(): void + { + $this->artisan('encrypted-data:re-encrypt:models', ['--path' => base_path('bootstrap')]) + ->expectsOutput('No models found.') + ->assertExitCode(1); + } + + #[DefineEnvironment('hasPreviousKeys')] + public function testAsksForConfirmation(): void + { + $this->artisan('encrypted-data:re-encrypt:models') + ->expectsConfirmation('The following models will be re-encrypted: '.PHP_EOL.SecretModel::class.PHP_EOL.'Do you want to continue?', 'no') + ->assertExitCode(1); + } + + #[DefineEnvironment('hasPreviousKeys')] + public function testAcceptsAbsolutePath(): void + { + $this->artisan('encrypted-data:re-encrypt:models', ['--path' => app_path('Models'), '--force' => true]) + ->assertExitCode(0); + } + + #[DefineEnvironment('hasPreviousKeys')] + public function testOutputsStatus(): void + { + $this->artisan('encrypted-data:re-encrypt:models', ['--force' => true]) + ->expectsOutput('Re-encrypting '.SecretModel::class.'...') + ->expectsOutput('Re-encrypting done!') + ->assertExitCode(0); + } + + #[DefineEnvironment('hasPreviousKeys')] + public function testModelOptionWorks(): void + { + $this->artisan('encrypted-data:re-encrypt:models', ['--model' => [SecretModel::class], '--force' => true]) + ->assertExitCode(0); + } + + #[DefineEnvironment('hasPreviousKeys')] + public function testExceptOptionExcludesModel(): void + { + $this->artisan('encrypted-data:re-encrypt:models', ['--except' => [SecretModel::class]]) + ->expectsOutput('No models found.') + ->assertExitCode(1); + } + + public function testFailsWhenModelAndExceptAreUsedTogether(): void + { + $this->expectException(\InvalidArgumentException::class); + $this->expectExceptionMessage('The --models and --except options cannot be combined.'); + + $this->artisan('encrypted-data:re-encrypt:models', [ + '--model' => [SecretModel::class], + '--except' => [SecretModel::class], + ]); + } + + public function testFailsWhenModelClassDoesNotExist(): void + { + $this->expectException(\InvalidArgumentException::class); + $this->expectExceptionMessage('Model class Workbench\App\Models\Foo\Bar\Baz does not exist.'); + + $this->artisan('encrypted-data:re-encrypt:models', ['--model' => ['Foo\Bar\Baz']]); + } + + public function testFailsWhenModelClassIsNotModel(): void + { + $this->expectException(\InvalidArgumentException::class); + $this->expectExceptionMessage('Class '.SomeClass::class.' is not a model.'); + + $this->artisan('encrypted-data:re-encrypt:models', ['--model' => [SomeClass::class]]); + } + + #[DefineEnvironment('hasPreviousKeys')] + public function testItReEncryptsModels(): void + { + // Create a model with encrypted attributes + /** @var \Workbench\App\Models\SecretModel $model */ + $model = SecretModel::create([ + 'encrypted_string' => 'foo', + 'encrypted_boolean' => true, + 'encrypted_date' => '2024-01-01', + 'encrypted_datetime' => '2024-01-01 12:00:00', + 'encrypted_immutable_date' => '2024-01-01', + 'encrypted_immutable_datetime' => '2024-01-01 12:00:00', + ]); + + $original = [ + 'encrypted_string' => $model->getRawOriginal('encrypted_string'), + 'encrypted_boolean' => $model->getRawOriginal('encrypted_boolean'), + 'encrypted_date' => $model->getRawOriginal('encrypted_date'), + 'encrypted_datetime' => $model->getRawOriginal('encrypted_datetime'), + 'encrypted_immutable_date' => $model->getRawOriginal('encrypted_immutable_date'), + 'encrypted_immutable_datetime' => $model->getRawOriginal('encrypted_immutable_datetime'), + ]; + + $this->artisan('encrypted-data:re-encrypt:models', ['--force' => true]) + ->assertExitCode(0); + + $model->refresh(); + + // Assert that the encrypted attributes have changed + $this->assertNotEquals($original['encrypted_string'], $model->getRawOriginal('encrypted_string')); + $this->assertNotEquals($original['encrypted_boolean'], $model->getRawOriginal('encrypted_boolean')); + $this->assertNotEquals($original['encrypted_date'], $model->getRawOriginal('encrypted_date')); + $this->assertNotEquals($original['encrypted_datetime'], $model->getRawOriginal('encrypted_datetime')); + $this->assertNotEquals($original['encrypted_immutable_date'], $model->getRawOriginal('encrypted_immutable_date')); + $this->assertNotEquals($original['encrypted_immutable_datetime'], $model->getRawOriginal('encrypted_immutable_datetime')); + + // Assert that the attributes themselves have not changed + $this->assertEquals('foo', $model->getAttribute('encrypted_string')); + $this->assertEquals(true, $model->getAttribute('encrypted_boolean')); + $this->assertEquals('2024-01-01 00:00:00', $model->getAttribute('encrypted_date')->toDateTimeString()); + $this->assertEquals('2024-01-01 12:00:00', $model->getAttribute('encrypted_datetime')->toDateTimeString()); + $this->assertEquals('2024-01-01 00:00:00', $model->getAttribute('encrypted_immutable_date')->toDateTimeString()); + $this->assertEquals('2024-01-01 12:00:00', $model->getAttribute('encrypted_immutable_datetime')->toDateTimeString()); + } + + #[DefineEnvironment('hasPreviousKeys')] + public function testItReEncryptsModelsWithoutTouchingTimestamps(): void + { + /** @var \Workbench\App\Models\SecretModel $model */ + $model = SecretModel::create([ + 'encrypted_string' => 'foo', + 'created_at' => '2024-01-01 12:00:00', + 'updated_at' => '2024-01-01 12:00:00', + ]); + $model->delete(); + + $original = [ + 'encrypted_string' => $model->getRawOriginal('encrypted_string'), + ]; + + $this->artisan('encrypted-data:re-encrypt:models', ['--force' => true]) + ->assertExitCode(0); + + $model->refresh(); + + $this->assertEquals($original['encrypted_string'], $model->getRawOriginal('encrypted_string')); + + $this->artisan('encrypted-data:re-encrypt:models', ['--with-trashed' => true, '--force' => true]) + ->assertExitCode(0); + + $model->refresh(); + + $this->assertNotEquals($original['encrypted_string'], $model->getRawOriginal('encrypted_string')); + } + + #[DefineEnvironment('hasPreviousKeys')] + public function testItReEncryptsModelsWithTrashed(): void + { + /** @var \Workbench\App\Models\SecretModel $model */ + $model = SecretModel::create([ + 'encrypted_string' => 'foo', + 'created_at' => '2024-01-01 12:00:00', + 'updated_at' => '2024-01-01 12:00:00', + ]); + + $this->artisan('encrypted-data:re-encrypt:models', ['--no-touch' => true, '--force' => true]) + ->assertExitCode(0); + + $model->refresh(); + + $this->assertEquals('2024-01-01 12:00:00', $model->getRawOriginal('updated_at')); + + $this->artisan('encrypted-data:re-encrypt:models', ['--force' => true]) + ->assertExitCode(0); + + $model->refresh(); + + $this->assertNotEquals('2024-01-01 12:00:00', $model->getRawOriginal('updated_at')); + } + + #[DefineEnvironment('hasPreviousKeys')] + public function testItReEncryptsModelsQuietly(): void + { + /** @var \Workbench\App\Models\SecretModel $model */ + $model = SecretModel::create([ + 'encrypted_string' => 'foo', + ]); + + // Fake events + \Event::fake(); + + $this->artisan('encrypted-data:re-encrypt:models', ['--quietly' => true, '--force' => true]) + ->assertExitCode(0); + + $model->refresh(); + + // Assert no events were raised + \Event::assertNotDispatched('eloquent.saving: '.SecretModel::class); + \Event::assertNotDispatched('eloquent.saved: '.SecretModel::class); + \Event::assertNotDispatched('eloquent.updating: '.SecretModel::class); + \Event::assertNotDispatched('eloquent.updated: '.SecretModel::class); + + // Now test with events enabled + \Event::fake(); + + $this->artisan('encrypted-data:re-encrypt:models', ['--force' => true]) + ->assertExitCode(0); + + $model->refresh(); + + // Assert events were raised + \Event::assertDispatched('eloquent.saving: '.SecretModel::class); + \Event::assertDispatched('eloquent.saved: '.SecretModel::class); + \Event::assertDispatched('eloquent.updating: '.SecretModel::class); + \Event::assertDispatched('eloquent.updated: '.SecretModel::class); + } +} diff --git a/workbench/app/Models/AbstractModel.php b/workbench/app/Models/AbstractModel.php new file mode 100644 index 0000000..1fd2d21 --- /dev/null +++ b/workbench/app/Models/AbstractModel.php @@ -0,0 +1,15 @@ + 'encrypted', + ]; + } +} diff --git a/workbench/app/Models/PlainModel.php b/workbench/app/Models/PlainModel.php new file mode 100644 index 0000000..9a169ce --- /dev/null +++ b/workbench/app/Models/PlainModel.php @@ -0,0 +1,17 @@ + 'boolean', + 'date' => 'date', + 'datetime' => 'datetime', + ]; + } +} diff --git a/workbench/app/Models/SecretModel.php b/workbench/app/Models/SecretModel.php new file mode 100644 index 0000000..8cb0745 --- /dev/null +++ b/workbench/app/Models/SecretModel.php @@ -0,0 +1,34 @@ + 'encrypted', + 'encrypted_boolean' => AsEncryptedBoolean::class, + 'encrypted_date' => AsEncryptedDate::class, + 'encrypted_datetime' => AsEncryptedDateTime::class, + 'encrypted_immutable_date' => AsEncryptedImmutableDate::class, + 'encrypted_immutable_datetime' => AsEncryptedImmutableDateTime::class, + + 'plain_boolean' => 'boolean', + 'plain_date' => 'date', + 'plain_datetime' => 'datetime', + ]; + } +} diff --git a/workbench/app/Models/SomeClass.php b/workbench/app/Models/SomeClass.php new file mode 100644 index 0000000..778c9e8 --- /dev/null +++ b/workbench/app/Models/SomeClass.php @@ -0,0 +1,8 @@ +id(); + + $table->text('encrypted_string')->nullable(); + $table->text('encrypted_boolean')->nullable(); + $table->text('encrypted_date')->nullable(); + $table->text('encrypted_datetime')->nullable(); + $table->text('encrypted_immutable_date')->nullable(); + $table->text('encrypted_immutable_datetime')->nullable(); + + $table->string('plain_string')->nullable(); + $table->boolean('plain_boolean')->nullable(); + $table->date('plain_date')->nullable(); + $table->dateTime('plain_datetime')->nullable(); + + $table->timestamps(); + $table->softDeletes(); + }); + } + + public function down(): void + { + Schema::dropIfExists('secret_models'); + } +}; diff --git a/workbench/storage/app/.gitignore b/workbench/storage/app/.gitignore new file mode 100644 index 0000000..e69de29 diff --git a/workbench/storage/app/public/.gitignore b/workbench/storage/app/public/.gitignore new file mode 100644 index 0000000..e69de29 diff --git a/workbench/storage/framework/.gitignore b/workbench/storage/framework/.gitignore new file mode 100644 index 0000000..e69de29 diff --git a/workbench/storage/framework/cache/.gitignore b/workbench/storage/framework/cache/.gitignore new file mode 100644 index 0000000..e69de29 diff --git a/workbench/storage/framework/data/.gitignore b/workbench/storage/framework/data/.gitignore new file mode 100644 index 0000000..e69de29 diff --git a/workbench/storage/framework/sessions/.gitignore b/workbench/storage/framework/sessions/.gitignore new file mode 100644 index 0000000..e69de29 diff --git a/workbench/storage/framework/testing/.gitignore b/workbench/storage/framework/testing/.gitignore new file mode 100644 index 0000000..e69de29 diff --git a/workbench/storage/framework/views/.gitignore b/workbench/storage/framework/views/.gitignore new file mode 100644 index 0000000..e69de29 diff --git a/workbench/storage/logs/.gitignore b/workbench/storage/logs/.gitignore new file mode 100644 index 0000000..e69de29