Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

add encryption behavior #291

Merged
merged 2 commits into from
Jan 14, 2024
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
46 changes: 46 additions & 0 deletions docs/Behavior/Encryption.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,46 @@
# Encryption Behavior

A CakePHP behavior to automatically encrypt and decrypt data passed through the ORM.

## Technical limitation
* Be aware, that your table columns need to be in a **binary** format and **large enough** to contain the encrypted payload. Something like `varbinary(1024)`
* You are no longer able to search, filter or join with those specific columns on a database level.
* The encryption key needs to be at least 32 characters long. See [here](https://book.cakephp.org/5/en/core-libraries/security.html) to learn more.

## Usage
Attach it to your model's `Table` class in its `initialize()` method like so:
```php
$this->addBehavior('Tools.Encryption', [
'fields' => ['secret_field'],
'key' => \Cake\Core\Configure::read('Security.encryption')
]);
```

After attaching the behavior a call like

```php
$user = $this->Users->newEmptyEntity();
$user = $this->Users->patchEntity($user, [
'username' => 'cake',
'password' => 'a random generated string hopefully'
'secret_field' => 'my super mysterious secret'
]);
$this->Users->save($user);
```

will result in the `secret_field` to be automatically encrypted.

Same goes for when you are fetching the entry from the ORM via

```php
$user = $this->Users->get($id);
// or
$users = $this->Users->find()->all();
```

will automatically decrypt the binary data.

## Recommendations

* Please do not use encryption if you don't need it! Password authentication for user login should always be implemented via hashing, not encryption.
* It is recommended to use a separate encryption key compared to your `Secruity.salt` value.
1 change: 1 addition & 0 deletions docs/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -35,6 +35,7 @@
* [Reset](Behavior/Reset.md)
* [String](Behavior/String.md)
* [Toggle](Behavior/Toggle.md)
* [Encryption](Behavior/Encryption.md)

### Components
* [Common](Component/Common.md)
Expand Down
111 changes: 111 additions & 0 deletions src/Model/Behavior/EncryptionBehavior.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,111 @@
<?php
/**
* @author Mark Scherer
* @license http://opensource.org/licenses/mit-license.php MIT
*/

namespace Tools\Model\Behavior;

use ArrayObject;
use Cake\Collection\CollectionInterface;
use Cake\Datasource\EntityInterface;
use Cake\Event\EventInterface;
use Cake\ORM\Behavior;
use Cake\ORM\Query\SelectQuery;
use Cake\Utility\Security;

/**
* Allows entity fields to be automatically encrypted when saving/updating and
* decrypted when fetching the data
*/
class EncryptionBehavior extends Behavior {

/**
* Default configuration.
*
* @var array<string, mixed>
*/
protected array $_defaultConfig = [
'fields' => [],
'key' => '',
];

/**
* @param array $config The config passed to the behavior
* @return void
*/
public function initialize(array $config): void {
if (isset($config['fields'])) {
$this->setConfig('fields', $config['fields']);
$this->_config['fields'] = array_unique($this->_config['fields']);
}
}

/**
* Events this listener is interested in.
*
* @return array<string, mixed>
*/
public function implementedEvents(): array {
return [
// Trigger this after app models beforeSave hook
'Model.beforeSave' => [
'callable' => 'beforeSave',
'priority' => 100,
],
'Model.beforeFind' => 'beforeFind',
];
}

/**
* Encrypting the fields
*
* @param \Cake\Event\EventInterface $event The event
* @param \Cake\Datasource\EntityInterface $entity The associated entity
* @param \ArrayObject $options Options passed to the event
* @return void
*/
public function beforeSave(EventInterface $event, EntityInterface $entity, ArrayObject $options): void {
$fields = $this->getConfig('fields');
$key = $this->getConfig('key');
foreach ($fields as $fieldName) :
if ($entity->has($fieldName)) :
$content = $entity->get($fieldName);
if (!empty($content)) :
$entity->set($fieldName, Security::encrypt($content, $key));
endif;
endif;
endforeach;
}

/**
* Decrypting the fields
*
* @param \Cake\Event\EventInterface $event The event
* @param \Cake\ORM\Query\SelectQuery $query The query to adjust
* @param \ArrayObject $options The options passed to the event
* @param bool $primary Whether the query is the root query or an associated query
* @return void
*/
public function beforeFind(EventInterface $event, SelectQuery $query, ArrayObject $options, bool $primary): void {
$query->formatResults(function (CollectionInterface $results) {
return $results->map(function ($row) {
$fields = $this->getConfig('fields');
$key = $this->getConfig('key');
foreach ($fields as $fieldName) :
if (isset($row->$fieldName) && is_resource($row->$fieldName)) :
$content = stream_get_contents($row->$fieldName);
if (!empty($content)) {
$row[$fieldName] = Security::decrypt($content, $key);
} else {
$row[$fieldName] = '';
}
endif;
endforeach;

return $row;
});
});
}

}
69 changes: 69 additions & 0 deletions tests/TestCase/Model/Behavior/EncryptionBehaviorTest.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,69 @@
<?php

namespace Tools\Test\TestCase\Model\Behavior;

use Cake\Datasource\ConnectionManager;
use Shim\TestSuite\TestCase;

class EncryptionBehaviorTest extends TestCase {

/**
* @var array
*/
protected array $fixtures = [
'plugin.Tools.Sessions',
];

/**
* @var \Tools\Model\Table\Table|\Tools\Model\Behavior\EncryptionBehavior
*/
protected $table;

/**
* @return void
*/
public function setUp(): void {
parent::setUp();

$this->table = $this->getTableLocator()->get('Sessions');
$this->table->addBehavior('Tools.Encryption', [
'fields' => ['data'],
'key' => 'some-very-long-key-which-needs-to-be-at-least-32-chars-long',
]);
}

/**
* @return void
*/
public function testSaveBasic() {
$data = [
'id' => 10,
'data' => 'test save',
];

$entity = $this->table->newEntity($data);
$entityAfter = $this->table->save($entity);
$this->assertTrue((bool)$entityAfter);

$connection = ConnectionManager::get('default');
$lastInsertedId = $connection->getDriver()->lastInsertId();
$result = $connection->getDriver()->execute('SELECT data FROM sessions WHERE id = :id', ['id' => $lastInsertedId])->fetchAll();
$this->assertNotEquals($data['data'], $result[0][0]);
}

/**
* @return void
*/
public function testFindBasic() {
$data = [
'id' => 10,
'data' => 'test save',
];
$entity = $this->table->newEntity($data);
$this->table->save($entity);

$entity = $this->table->get(10);
$this->assertEquals('test save', $entity->data);
}

}