Browse Source

Merge pull request #291 from LordSimal/cake5

add encryption behavior
Mark Scherer 1 year ago
parent
commit
8e0a57ab07

+ 46 - 0
docs/Behavior/Encryption.md

@@ -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 - 0
docs/README.md

@@ -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)

+ 111 - 0
src/Model/Behavior/EncryptionBehavior.php

@@ -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 - 0
tests/TestCase/Model/Behavior/EncryptionBehaviorTest.php

@@ -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);
+	}
+
+}