Initial commit
This commit is contained in:
commit
3f0e667eab
|
|
@ -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/
|
||||
|
|
@ -0,0 +1,7 @@
|
|||
.PHONY: test tag
|
||||
|
||||
test:
|
||||
@./vendor/bin/phpunit
|
||||
|
||||
tag:
|
||||
@./tag.sh
|
||||
|
|
@ -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"
|
||||
]
|
||||
}
|
||||
}
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
|
|
@ -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'),
|
||||
|
||||
];
|
||||
|
|
@ -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**!
|
||||
|
|
@ -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>
|
||||
|
|
@ -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
|
||||
```
|
||||
|
|
@ -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;
|
||||
}
|
||||
}
|
||||
|
|
@ -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
|
||||
);
|
||||
}
|
||||
}
|
||||
|
|
@ -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);
|
||||
}
|
||||
}
|
||||
|
|
@ -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());
|
||||
}
|
||||
}
|
||||
|
|
@ -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]);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -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";
|
||||
}
|
||||
}
|
||||
|
|
@ -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);
|
||||
}
|
||||
|
|
@ -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);
|
||||
}
|
||||
|
|
@ -0,0 +1,10 @@
|
|||
<?php
|
||||
|
||||
namespace BjornVoesten\CipherSweet\Exceptions;
|
||||
|
||||
use Exception;
|
||||
|
||||
class CipherSweetException extends Exception
|
||||
{
|
||||
//
|
||||
}
|
||||
|
|
@ -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;
|
||||
}
|
||||
}
|
||||
|
|
@ -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);
|
||||
};
|
||||
}
|
||||
}
|
||||
|
|
@ -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."
|
||||
|
|
@ -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();
|
||||
});
|
||||
}
|
||||
}
|
||||
|
|
@ -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,
|
||||
]);
|
||||
}
|
||||
}
|
||||
|
|
@ -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,
|
||||
];
|
||||
}
|
||||
|
|
@ -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
|
||||
);
|
||||
}
|
||||
}
|
||||
|
|
@ -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',
|
||||
])
|
||||
);
|
||||
}
|
||||
}
|
||||
|
|
@ -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')
|
||||
);
|
||||
}
|
||||
}
|
||||
|
|
@ -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();
|
||||
}
|
||||
}
|
||||
Loading…
Reference in New Issue