Initial commit

This commit is contained in:
Bjorn Voesten 2020-11-02 21:40:58 +01:00
commit 3f0e667eab
27 changed files with 8061 additions and 0 deletions

13
.gitignore vendored Normal file
View File

@ -0,0 +1,13 @@
/node_modules
/public/storage
/storage/*.key
/vendor
.env
.env.backup
.phpunit.result.cache
Homestead.json
Homestead.yaml
npm-debug.log
yarn-error.log
.idea/
.vagrant/

7
Makefile Normal file
View File

@ -0,0 +1,7 @@
.PHONY: test tag
test:
@./vendor/bin/phpunit
tag:
@./tag.sh

40
composer.json Normal file
View File

@ -0,0 +1,40 @@
{
"name": "bjorn-voesten/ciphersweet-for-laravel",
"description": "",
"license": "MIT",
"authors": [
{
"name": "Bjorn Voesten",
"email": "bjorn-voesten@live.nl"
}
],
"autoload": {
"psr-4": {
"BjornVoesten\\CipherSweet\\": "src/",
"BjornVoesten\\CipherSweet\\Seeds\\": "database/seeds/"
}
},
"autoload-dev": {
"psr-4": {
"Tests\\": "tests/"
}
},
"require": {
"php": "^7.2.5",
"illuminate/config": "^7.0|^8.0",
"illuminate/support": "^7.0|^8.0",
"illuminate/database": "^7.0|^8.0",
"paragonie/ciphersweet": "^2.0"
},
"require-dev": {
"orchestra/testbench": "^5.0|^6.0",
"phpunit/phpunit": "^9.0"
},
"extra": {
"laravel": {
"providers": [
"BjornVoesten\\CipherSweet\\CipherSweetServiceProvider"
]
}
}
}

6645
composer.lock generated Normal file

File diff suppressed because it is too large Load Diff

21
config/ciphersweet.php Normal file
View File

@ -0,0 +1,21 @@
<?php
return [
/*
* The key should be a string with a size of 32 bytes and is
* used to generate keys when encrypting attributes. Please set
* the key before deploying the application and do not change
* the key when you already have attributes encrypted!
*/
'key' => env('CIPHERSWEET_KEY'),
/*
* You may specify which encryption algorithm has to be used
* to encrypt all attributes.
*
* Supported: "modern", "fips"
*/
'crypto' => env('CIPHERSWEET_CRYPTO', 'modern'),
];

13
contributing.md Normal file
View File

@ -0,0 +1,13 @@
# Contributing
Contributions are welcome and will be accepted via pull requests on [Github](https://github.com/bjornvoesten/ciphersweet-for-laravel).
## Pull Requests
- **Document any change in behaviour** - Make sure the `readme.md` and any other relevant documentation are kept up-to-date.
- **One pull request per feature** - If you want to do more than one thing, send multiple pull requests.
- **Send coherent history** - Make sure each individual commit in your pull request is meaningful. If you had to make multiple intermediate commits while developing, please [squash them](http://www.git-scm.com/book/en/v2/Git-Tools-Rewriting-History#Changing-Multiple-Commit-Messages) before submitting.
**Happy coding**!

36
phpunit.xml Normal file
View File

@ -0,0 +1,36 @@
<?xml version="1.0" encoding="UTF-8"?>
<phpunit xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
xsi:noNamespaceSchemaLocation="./vendor/phpunit/phpunit/phpunit.xsd"
backupGlobals="false"
backupStaticAttributes="false"
bootstrap="vendor/autoload.php"
colors="true"
convertErrorsToExceptions="true"
convertNoticesToExceptions="true"
convertWarningsToExceptions="true"
processIsolation="false"
stopOnFailure="false">
<testsuites>
<testsuite name="Unit">
<directory suffix="Test.php">./tests/Unit</directory>
</testsuite>
<testsuite name="Feature">
<directory suffix="Test.php">./tests/Feature</directory>
</testsuite>
</testsuites>
<filter>
<whitelist processUncoveredFilesFromWhitelist="true">
<directory suffix=".php">./src</directory>
</whitelist>
</filter>
<php>
<server name="APP_ENV" value="testing"/>
<server name="BCRYPT_ROUNDS" value="4"/>
<server name="CACHE_DRIVER" value="array"/>
<server name="MAIL_DRIVER" value="array"/>
<server name="QUEUE_CONNECTION" value="sync"/>
<server name="SESSION_DRIVER" value="file"/>
<server name="DB_CONNECTION" value="testing"/>
</php>
</phpunit>

134
readme.md Normal file
View File

@ -0,0 +1,134 @@
# CipherSweet for Laravel
A Laravel implementation of [Paragon Initiative Enterprises CipherSweet](https://ciphersweet.paragonie.com) searchable field level encryption.
Make sure you have some basic understanding of CipherSweet before continuing.
## Installation
Install the package using composer:
```
composer require bjorn-voesten/laravel-ciphersweet
```
The package will then automatically register itself.
#### Encryption key
In your `.env` file you should add:
```dotenv
CIPHERSWEET_KEY=
```
And then generate an encryption key:
```
php artisan ciphersweet:key
```
#### Config file
Publish the config file:
```
php artisan vendor:publish --tag=ciphersweet-config
```
## Usage
### Define encryption
Add the `BjornVoesten\CipherSweet\Concerns\WithAttributeEncryption` trait to your model <br>
and add the `BjornVoesten\CipherSweet\Casts\Encrypted` cast to the attributes you want to encrypt.
```php
<?php
use Illuminate\Database\Eloquent\Model;
use BjornVoesten\CipherSweet\Concerns\WithAttributeEncryption;
use BjornVoesten\CipherSweet\Casts\Encrypted;
class User extends Model
{
use WithAttributeEncryption;
protected $fillable = [
'social_security_number',
];
protected $casts = [
'social_security_number' => Encrypted::class,
];
}
```
By default, the index column name is generated using the name suffixed by `_index`. <br>
So `social_security_number` will use `social_security_number_index`.
#### Using custom indexes
Alternatively you can define multiple indexes per attribute and and define more options.
```php
<?php
use Illuminate\Database\Eloquent\Model;
use BjornVoesten\CipherSweet\Concerns\WithAttributeEncryption;
use BjornVoesten\CipherSweet\Casts\Encrypted;
use BjornVoesten\CipherSweet\Contracts\Attribute;
use BjornVoesten\CipherSweet\Contracts\Index;
class User extends Model
{
// ...
/**
* Encrypt the social security number.
*
* @param \BjornVoesten\CipherSweet\Contracts\Attribute $attribute
* @return void
*/
public function encryptSocialSecurityNumberAttribute(Attribute $attribute): void
{
$attribute->index('social_security_number_last_four_index', function (Index $index) {
$index
->bits(16)
->transform(new LastFourDigits());
});
}
}
```
### Searching
**Note** When searching with the `equal to` operator models will be returned when the value is found in one of all available or defined indexes. When searching with the `not equal to` operator all models where the value is not found in any of the available or the defined indexes are returned.
**Note**
Because of the limited search possibilities in CipherSweet only the `=` and `!=` operators are available when searching encrypted attributes.
#### `whereEncrypted`
```php
User::query()
->whereEncrypted('social_security_number', '=', '123-456-789')
->get();
```
#### `orWhereEncrypted`
```php
User::query()
->whereEncrypted('social_security_number', '=', '123-456-789')
->orWhereEncrypted('social_security_number', '=', '456-123-789')
->get();
```
## Contributing
Please see [contributing.md](contributing.md) for details and a todolist.
## Security
If you discover any security related issues, please email [security@bjornvoesten.com](mailto:security@bjornvoesten.com) instead of using the issue tracker.
## Testing
```
make test
```

39
src/Attribute.php Normal file
View File

@ -0,0 +1,39 @@
<?php
namespace BjornVoesten\CipherSweet;
class Attribute implements Contracts\Attribute
{
/**
* @var string
*/
public $column;
/**
* @var array
*/
public $indexes = [];
public function __construct(string $column)
{
$this->column = $column;
}
/**
* Add a new attribute index.
*
* @param string $column
* @param callable|null $callback
* @return $this
*/
public function index(string $column, callable $callback = null)
{
$index = new Index($column);
if ($callback) $callback($index);
$this->indexes[] = $index;
return $this;
}
}

43
src/Casts/Encrypted.php Normal file
View File

@ -0,0 +1,43 @@
<?php
namespace BjornVoesten\CipherSweet\Casts;
use Illuminate\Contracts\Database\Eloquent\CastsAttributes;
class Encrypted implements CastsAttributes
{
/**
* Cast the given value.
*
* @param \Illuminate\Database\Eloquent\Model|\BjornVoesten\CipherSweet\Concerns\WithAttributeEncryption $model
* @param string $key
* @param mixed $value
* @param array $attributes
* @return string
* @throws \Exception
*/
public function get($model, string $key, $value, array $attributes)
{
return $model->decrypt($key);
}
/**
* Prepare the given value for storage.
*
* @param \Illuminate\Database\Eloquent\Model|\BjornVoesten\CipherSweet\Concerns\WithAttributeEncryption $model
* @param string $key
* @param mixed $value
* @param array $attributes
* @return array
* @throws \Exception
*/
public function set($model, string $key, $value, array $attributes)
{
[$ciphertext, $indexes] = $model->encrypt($key, $value);
return array_merge(
[$key => $ciphertext],
$indexes
);
}
}

167
src/CipherSweetService.php Normal file
View File

@ -0,0 +1,167 @@
<?php
namespace BjornVoesten\CipherSweet;
use BjornVoesten\CipherSweet\Contracts\Index;
use BjornVoesten\CipherSweet\Exceptions\CipherSweetException;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Support\Str;
use ParagonIE\CipherSweet\Backend\FIPSCrypto;
use ParagonIE\CipherSweet\Backend\ModernCrypto;
use ParagonIE\CipherSweet\BlindIndex;
use ParagonIE\CipherSweet\CipherSweet;
use ParagonIE\CipherSweet\EncryptedField;
use ParagonIE\CipherSweet\KeyProvider\StringProvider;
class CipherSweetService
{
/**
* @var \ParagonIE\CipherSweet\CipherSweet
*/
protected $engine;
/**
* @var \ParagonIE\CipherSweet\Contract\KeyProviderInterface|\ParagonIE\CipherSweet\KeyProvider\StringProvider
*/
protected $provider;
/**
* Create a new encryption service.
*
* @return void
* @throws \BjornVoesten\CipherSweet\Exceptions\CipherSweetException
* @throws \ParagonIE\CipherSweet\Exception\CryptoOperationException
*/
public function __construct()
{
$this->provider = $this->provider();
$this->engine = new CipherSweet(
$this->provider, $this->crypto()
);
}
/**
* Create the string provider.
*
* @return \ParagonIE\CipherSweet\Contract\KeyProviderInterface
* @throws \BjornVoesten\CipherSweet\Exceptions\CipherSweetException
* @throws \ParagonIE\CipherSweet\Exception\CryptoOperationException
*/
private function provider()
{
$key = config('ciphersweet.key');
if (empty($key)) {
throw new CipherSweetException(
'No encryption key provided'
);
}
return new StringProvider($key);
}
/**
* Create the crypto provider instance.
*
* @return \ParagonIE\CipherSweet\Contract\BackendInterface
* @throws \BjornVoesten\CipherSweet\Exceptions\CipherSweetException
*/
protected function crypto()
{
switch (config('ciphersweet.crypto')) {
case 'fips':
return new FIPSCrypto();
case 'modern':
return new ModernCrypto();
default:
throw new CipherSweetException(
'Unsupported crypto'
);
}
}
/**
* Get an encrypted field instance for the given attribute.
*
* @param \Illuminate\Database\Eloquent\Model $model
* @param string $attribute
* @return \ParagonIE\CipherSweet\EncryptedField
* @throws \ParagonIE\CipherSweet\Exception\BlindIndexNameCollisionException
* @throws \ParagonIE\CipherSweet\Exception\CryptoOperationException
*/
protected function field(Model $model, string $attribute)
{
$attribute = new Attribute($attribute);
// Check whether a method for custom encryption exists and get the
// custom indexes from the method, or set the default index.
$method = 'encrypt' . Str::studly($attribute->column) . 'Attribute';
if (method_exists($model, $method)) {
$model->{$method}($attribute);
} else {
$attribute->index($attribute->column . '_index');
}
// Create the encrypted field instance.
$field = new EncryptedField(
$this->engine,
$table = $model->getTable(),
$attribute->column,
);
// Map and add the indexes to the encrypted
// field instance.
collect($attribute->indexes)
->map(function (Index $index) {
return $index = new BlindIndex(
$index->column,
$index->transformers,
$index->bits,
$index->fast,
);
})
->each(
fn($index) => $field->addBlindIndex($index)
);
return $field;
}
/**
* Encrypt a model attribute.
*
* @param \Illuminate\Database\Eloquent\Model|\BjornVoesten\CipherSweet\Concerns\WithAttributeEncryption $model
* @param string $attribute
* @param string|int|boolean $value
* @return array
* @throws \ParagonIE\CipherSweet\Exception\BlindIndexNameCollisionException
* @throws \ParagonIE\CipherSweet\Exception\BlindIndexNotFoundException
* @throws \ParagonIE\CipherSweet\Exception\CryptoOperationException
* @throws \SodiumException
*/
public function encrypt(Model $model, string $attribute, $value)
{
return $this
->field($model, $attribute)
->prepareForStorage($value);
}
/**
* Decrypt a model attribute.
*
* @param \Illuminate\Database\Eloquent\Model|\BjornVoesten\CipherSweet\Concerns\WithAttributeEncryption $model
* @param string $attribute
* @param string|int|boolean $value
* @return string
* @throws \ParagonIE\CipherSweet\Exception\BlindIndexNameCollisionException
* @throws \ParagonIE\CipherSweet\Exception\CryptoOperationException
*/
public function decrypt(Model $model, string $attribute, $value)
{
return $this
->field($model, $attribute)
->decryptValue($value);
}
}

View File

@ -0,0 +1,50 @@
<?php
namespace BjornVoesten\CipherSweet;
use BjornVoesten\CipherSweet\Console\Commands\KeyGenerate;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\ServiceProvider;
class CipherSweetServiceProvider extends ServiceProvider
{
/**
* Register services.
*
* @return void
* @throws \Exception
*/
public function register()
{
// Container
$this->app->singleton(
'ciphersweet',
CipherSweetService::class,
);
// Commands
$this->commands(KeyGenerate::class);
}
/**
* Bootstrap services.
*
* @return void
* @throws \Exception
*/
public function boot()
{
// Config
$this->publishes([
__DIR__ . '/../config/ciphersweet.php',
], 'ciphersweet-config');
$this->mergeConfigFrom(
__DIR__ . '/../config/ciphersweet.php',
'ciphersweet-config'
);
// Blueprint macros
Blueprint::mixin(new Macros\Blueprint());
}
}

View File

@ -0,0 +1,105 @@
<?php
namespace BjornVoesten\CipherSweet\Concerns;
use Illuminate\Database\Eloquent\Builder;
use Illuminate\Support\Arr;
/**
* @mixin \Illuminate\Database\Eloquent\Model
*/
trait WithAttributeEncryption
{
/**
* Encrypt the value for an attribute.
*
* @param string $attribute
* @param string|int|boolean $value
* @return array
* @throws \Exception
*/
public function encrypt(string $attribute, $value)
{
[$ciphertext, $indexes] = $result = app('ciphersweet')->encrypt(
$this, $attribute, $value
);
$this->attributes[$attribute] = $ciphertext;
foreach ($indexes as $value => $index) {
$this->attributes[$index] = $value;
}
return $result;
}
/**
* Encrypt the attribute.
*
* @param string $attribute
* @return $this
*/
public function decrypt(string $attribute)
{
return app('ciphersweet')->decrypt(
$this, $attribute, $this->attributes[$attribute]
);
}
/**
* Add a where clause to the query for an encrypted column.
*
* @param \Illuminate\Database\Eloquent\Builder $query
* @param string $column
* @param $operator
* @param $value
* @param array $indexes
* @return void
* @throws \Exception
*/
public function scopeWhereEncrypted(Builder $query, string $column, $operator, $value, array $indexes = []): void
{
$available = Arr::last(
$this->encrypt($column, $value)
);
$indexes = empty($indexes)
? array_keys($available)
: $indexes;
$first = true;
foreach ($indexes as $index) {
$first
? $query->where($index, $operator, $available[$index])
: $query->orWhere($index, $operator, $available[$index]);
$first = false;
}
}
/**
* Add an or where clause to the query for an encrypted column.
*
* @param \Illuminate\Database\Eloquent\Builder $query
* @param string $column
* @param string $operator
* @param $value
* @param array $indexes
* @return void
* @throws \Exception
*/
public function scopeOrWhereEncrypted(Builder $query, string $column, string $operator, $value, array $indexes = []): void
{
$available = Arr::last(
$this->encrypt($column, $value)
);
$indexes = empty($indexes)
? array_keys($available)
: $indexes;
foreach ($indexes as $index) {
$query->orWhere($index, $operator, $available[$index]);
}
}
}

View File

@ -0,0 +1,134 @@
<?php
namespace BjornVoesten\CipherSweet\Console\Commands;
use Illuminate\Console\Command;
use Illuminate\Console\ConfirmableTrait;
use Illuminate\Support\Str;
use ParagonIE\ConstantTime\Hex;
class KeyGenerate extends Command
{
use ConfirmableTrait;
/**
* The name and signature of the console command.
*
* @var string
*/
protected $signature = 'ciphersweet:key
{--show : Display the key instead of modifying files}
{--force : Force the operation to run when in production}';
/**
* The console command description.
*
* @var string
*/
protected $description = 'Generate and set a random encryption key';
/**
* Execute the console command.
*
* @return void
* @throws \Exception
*/
public function handle()
{
$key = $this->generateRandomKey();
if ($this->option('show')) {
$this->line('<comment>' . $key . '</comment>');
return;
}
// Next, we will replace the application key in the environment file so it is
// automatically setup for this developer. This key gets generated using a
// secure random byte generator and is later base64 encoded for storage.
if (!$this->setKeyInEnvironmentFile($key)) {
return;
}
$this->laravel['config']['ciphersweet.key'] = $key;
$this->info('Application key set successfully.');
}
/**
* Generate a random key for the application.
*
* @return string
* @throws \Exception
*/
protected function generateRandomKey()
{
return Hex::encode(random_bytes(32));
}
/**
* Set the application key in the environment file.
*
* @param string $key
* @return bool
*/
protected function setKeyInEnvironmentFile($key)
{
$currentKey = env('CIPHERSWEET_KEY');
if (strlen($currentKey)) {
return false;
}
if (!$this->confirmToProceed()) {
return false;
}
$this->writeNewEnvironmentFileWith($key);
return true;
}
/**
* Write a new environment file with the given key.
*
* @param string $key
* @return void
*/
protected function writeNewEnvironmentFileWith($key)
{
$content = file_get_contents(
$this->laravel->environmentFilePath()
);
if (!Str::contains($content, 'CIPHERSWEET_KEY')) {
file_put_contents(
$this->laravel->environmentFilePath(),
'CIPHERSWEET_KEY=' . $key,
FILE_APPEND
);
return;
}
file_put_contents(
$this->laravel->environmentFilePath(),
preg_replace(
$this->keyReplacementPattern(),
'CIPHERSWEET_KEY=' . $key,
$content
)
);
}
/**
* Get a regex pattern that will match env APP_KEY with any random key.
*
* @return string
*/
protected function keyReplacementPattern()
{
$escaped = preg_quote('=' . $this->laravel['config']['ciphersweet.key'], '/');
return "/^CIPHERSWEET_KEY{$escaped}/m";
}
}

View File

@ -0,0 +1,15 @@
<?php
namespace BjornVoesten\CipherSweet\Contracts;
interface Attribute
{
/**
* Add a new attribute index.
*
* @param string $column
* @param callable|null $callback
* @return $this
*/
public function index(string $column, callable $callback = null);
}

32
src/Contracts/Index.php Normal file
View File

@ -0,0 +1,32 @@
<?php
namespace BjornVoesten\CipherSweet\Contracts;
use ParagonIE\CipherSweet\Contract\TransformationInterface;
interface Index
{
/**
* Set the index bits.
*
* @param int $bits
* @return $this
*/
public function bits(int $bits);
/**
* Set the index speed to fast.
*
* @param bool $fast
* @return $this
*/
public function fast(bool $fast = true);
/**
* Add a transformer.
*
* @param \ParagonIE\CipherSweet\Contract\TransformationInterface $transformer
* @return $this
*/
public function transform(TransformationInterface $transformer);
}

View File

@ -0,0 +1,10 @@
<?php
namespace BjornVoesten\CipherSweet\Exceptions;
use Exception;
class CipherSweetException extends Exception
{
//
}

72
src/Index.php Normal file
View File

@ -0,0 +1,72 @@
<?php
namespace BjornVoesten\CipherSweet;
use ParagonIE\CipherSweet\Contract\TransformationInterface;
class Index implements Contracts\Index
{
/**
* @var string
*/
public $column;
/**
* @var int
*/
public $bits = 32;
/**
* @var bool
*/
public $fast = true;
/**
* @var array
*/
public $transformers = [];
public function __construct(string $column)
{
$this->column = $column;
}
/**
* Set the index bits.
*
* @param int $bits
* @return $this
*/
public function bits(int $bits)
{
$this->bits = $bits;
return $this;
}
/**
* Set the index speed to fast.
*
* @param bool $fast
* @return $this
*/
public function fast(bool $fast = true)
{
$this->fast = $fast;
return $this;
}
/**
* Add a transformer.
*
* @param \ParagonIE\CipherSweet\Contract\TransformationInterface $transformer
* @return $this
*/
public function transform(TransformationInterface $transformer)
{
$this->transformers[] = $transformer;
return $this;
}
}

53
src/Macros/Blueprint.php Normal file
View File

@ -0,0 +1,53 @@
<?php
namespace BjornVoesten\CipherSweet\Macros;
use Closure;
/**
* @mixin \Illuminate\Database\Schema\Blueprint
*/
class Blueprint
{
public function encrypted(): Closure
{
return function (string $name, ?array $indexes = null): void {
$columns = empty($indexes)
? [
$name,
"{$name}_index",
]
: array_merge(
[$name],
$indexes
);
foreach ($columns as $column) {
$this->string($column);
}
$this->index($columns);
};
}
public function nullableEncrypted(): Closure
{
return function (string $name, ?array $indexes = null): void {
$columns = empty($indexes)
? [
$name,
"{$name}_index",
]
: array_merge(
[$name],
$indexes
);
foreach ($columns as $column) {
$this->string($column)->nullable();
}
$this->index($columns);
};
}
}

17
tag.sh Executable file
View File

@ -0,0 +1,17 @@
RED='\033[0;31m'
GREEN='\033[0;32m'
# Get the version
NEW_VERSION=$(cat .version)
# Check if the version already exists
if [ $(git tag -l "$NEW_VERSION") ]; then
echo "${RED}Version already exists!"
exit
fi
# Create and push the tag
git tag $NEW_VERSION
git push --tags
echo "${GREEN}Done."

View File

@ -0,0 +1,20 @@
<?php
namespace Tests\Concerns;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
trait CreateUsersTable
{
protected function createUsersTable(): void
{
Schema::create('users', function (Blueprint $table) {
$table->id('id');
$table->string('social_security_number');
$table->string('social_security_number_index')->nullable();
$table->string('custom_index')->nullable();
$table->timestamps();
});
}
}

View File

@ -0,0 +1,21 @@
<?php
namespace Tests\Concerns;
use Tests\Mocks\User;
trait CreatesUsers
{
/**
* Create a new user instance.
*
* @param string $socialSecurityNumber
* @return \Tests\Mocks\User|\Illuminate\Database\Eloquent\Model
*/
protected function user(string $socialSecurityNumber): User
{
return User::query()->create([
'social_security_number' => $socialSecurityNumber,
]);
}
}

22
tests/Mocks/User.php Normal file
View File

@ -0,0 +1,22 @@
<?php
namespace Tests\Mocks;
use BjornVoesten\CipherSweet\Casts\Encrypted;
use BjornVoesten\CipherSweet\Concerns\WithAttributeEncryption;
use Illuminate\Database\Eloquent\Model;
class User extends Model
{
use WithAttributeEncryption;
protected $table = 'users';
protected $fillable = [
'social_security_number',
];
protected $casts = [
'social_security_number' => Encrypted::class,
];
}

69
tests/TestCase.php Normal file
View File

@ -0,0 +1,69 @@
<?php
namespace Tests;
use Illuminate\Foundation\Testing\Concerns\InteractsWithDatabase;
use Illuminate\Foundation\Testing\DatabaseMigrations;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Orchestra\Testbench\TestCase as Orchestra;
abstract class TestCase extends Orchestra
{
use InteractsWithDatabase;
use RefreshDatabase;
use DatabaseMigrations;
/**
* Get package providers.
*
* @param \Illuminate\Foundation\Application $app
*
* @return array
*/
protected function getPackageProviders($app)
{
return [
\BjornVoesten\CipherSweet\CipherSweetServiceProvider::class,
];
}
/**
* Define environment setup.
*
* @param \Illuminate\Foundation\Application $app
* @return void
*/
protected function getEnvironmentSetUp($app)
{
$app['config']->set(
'app.key',
'base64:Hn0XYG6Inl5TLOKtd+M3+sf6nfwfMsT0iF9Zf4ww5K0='
);
$app['config']->set(
'ciphersweet.key',
'4e1c44f87b4cdf21808762970b356891db180a9dd9850e7baf2a79ff3ab8a2fc'
);
$app['config']->set(
'ciphersweet.crypto',
'modern',
);
}
protected function assertDatabaseHasFor(string $model, array $data)
{
return $this->assertDatabaseHas(
(new $model)->getTable(),
$data
);
}
protected function assertDatabaseMissingFor(string $model, array $data)
{
return $this->assertDatabaseMissing(
(new $model)->getTable(),
$data
);
}
}

View File

@ -0,0 +1,74 @@
<?php
namespace Tests\Unit;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
use Tests\TestCase;
class BlueprintTest extends TestCase
{
public function testBlueprintEncryptedColumn(): void
{
Schema::create('users', function (Blueprint $table) {
$table->encrypted('social_security_number');
});
$this->assertTrue(
Schema::hasColumns('users', [
'social_security_number',
'social_security_number_index',
])
);
}
public function testBlueprintEncryptedColumnWithCustomIndexes(): void
{
Schema::create('users', function (Blueprint $table) {
$table->encrypted('social_security_number', [
'social_security_number_index',
'custom_index',
]);
});
$this->assertTrue(
Schema::hasColumns('users', [
'social_security_number',
'social_security_number_index',
'custom_index',
])
);
}
public function testBlueprintNullableEncryptedColumn(): void
{
Schema::create('users', function (Blueprint $table) {
$table->nullableEncrypted('social_security_number');
});
$this->assertTrue(
Schema::hasColumns('users', [
'social_security_number',
'social_security_number_index',
])
);
}
public function testBlueprintNullableEncryptedColumnWithCustomIndexes(): void
{
Schema::create('users', function (Blueprint $table) {
$table->nullableEncrypted('social_security_number', [
'social_security_number_index',
'custom_index',
]);
});
$this->assertTrue(
Schema::hasColumns('users', [
'social_security_number',
'social_security_number_index',
'custom_index',
])
);
}
}

View File

@ -0,0 +1,108 @@
<?php
namespace Tests\Unit;
use BjornVoesten\CipherSweet\Contracts\Attribute;
use BjornVoesten\CipherSweet\Contracts\Index;
use Tests\Concerns\CreatesUsers;
use Tests\Concerns\CreateUsersTable;
use Tests\Mocks\User;
use Tests\TestCase;
class EncryptionTest extends TestCase
{
use CreateUsersTable;
use CreatesUsers;
protected function setUp(): void
{
parent::setUp();
$this->createUsersTable();
}
public function testAttributesAreEncryptedWhenMade(): void
{
$user = new User([
'social_security_number' => '123-456-789',
]);
$this->assertSame(
'123-456-789',
$user->social_security_number
);
$this->assertNotEmpty(
$user->social_security_number_index
);
}
public function testAttributesAreEncryptedWhenCreated(): void
{
$user = $this->user('123-456-789');
$this->assertNotSame(
'123-456-789',
$user->getRawOriginal('social_security_number')
);
$this->assertNotEmpty(
$user->social_security_number_index
);
$this->assertDatabaseHasFor(User::class, [
'social_security_number' => $user->getRawOriginal('social_security_number'),
]);
$this->assertDatabaseMissingFor(User::class, [
'social_security_number' => $user->getAttribute('social_security_number'),
]);
}
public function testAttributesAreEncryptedWithCustomIndexes(): void
{
$user = new class extends User {
public function encryptSocialSecurityNumberAttribute(Attribute $attribute): void
{
$attribute->index('custom_index', function (Index $index) {
$index
->bits(32)
->fast();
});
}
};
$user
->fill([
'social_security_number' => '123-456-789',
])
->save();
$this->assertNotSame(
'123-456-789',
$user->getRawOriginal('social_security_number')
);
$this->assertNotEmpty(
$user->getAttribute('custom_index')
);
$this->assertDatabaseHasFor(User::class, [
'social_security_number' => $user->getRawOriginal('social_security_number'),
]);
$this->assertDatabaseMissingFor(User::class, [
'social_security_number' => $user->getAttribute('social_security_number'),
]);
}
public function testAttributesAreDecryptedWhenAccessed(): void
{
$user = $this->user('123-456-789');
$this->assertSame(
'123-456-789',
$user->getAttribute('social_security_number')
);
}
}

101
tests/Unit/QueryTest.php Normal file
View File

@ -0,0 +1,101 @@
<?php
namespace Tests\Unit;
use Exception;
use Tests\Concerns\CreatesUsers;
use Tests\Concerns\CreateUsersTable;
use Tests\Mocks\User;
use Tests\TestCase;
class QueryTest extends TestCase
{
use CreateUsersTable;
use CreatesUsers;
protected function setUp(): void
{
parent::setUp();
$this->createUsersTable();
}
public function testCanQueryEncryptedAttributeWithWhereClause(): void
{
$userOne = $this->user('123-456-789');
$userTwo = $this->user('789-456-123');
// Assert success.
/** @var \Illuminate\Database\Eloquent\Collection $keys */
$keys = User::query()
->whereEncrypted('social_security_number', '=', '123-456-789')
->get()
->modelKeys();
$this->assertContains($userOne->id, $keys);
$this->assertNotContains($userTwo->id, $keys);
// Assert success using provided index.
/** @var \Illuminate\Database\Eloquent\Collection $keys */
$keys = User::query()
->whereEncrypted('social_security_number', '=', '123-456-789', [
'social_security_number_index',
])
->get()
->modelKeys();
$this->assertContains($userOne->id, $keys);
$this->assertNotContains($userTwo->id, $keys);
// Assert undefined index exception.
$this->expectException(Exception::class);
User::query()
->whereEncrypted('social_security_number', '=', '123-456-789', [
'non_existing_index',
])
->get();
}
public function testCanQueryEncryptedAttributeWithOrWhereClause(): void
{
$userOne = $this->user('123-456-789');
$userTwo = $this->user('456-123-789');
$userThree = $this->user('789-456-123');
// Assert success.
/** @var \Illuminate\Database\Eloquent\Collection $keys */
$keys = User::query()
->whereEncrypted('social_security_number', '=', '123-456-789')
->orWhereEncrypted('social_security_number', '=', '789-456-123')
->get()
->modelKeys();
$this->assertContains($userOne->id, $keys);
$this->assertNotContains($userTwo->id, $keys);
$this->assertContains($userThree->id, $keys);
// Assert success using provided index.
/** @var \Illuminate\Database\Eloquent\Collection $keys */
$keys = User::query()
->whereEncrypted('social_security_number', '=', '123-456-789')
->orWhereEncrypted('social_security_number', '=', '789-456-123', [
'social_security_number_index',
])
->get()
->modelKeys();
$this->assertContains($userOne->id, $keys);
$this->assertNotContains($userTwo->id, $keys);
$this->assertContains($userThree->id, $keys);
// Assert undefined index exception.
$this->expectException(Exception::class);
User::query()
->orWhereEncrypted('social_security_number', '=', '123-456-789', [
'non_existing_index',
])
->get();
}
}