Skip to content
Open
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
2 changes: 1 addition & 1 deletion structarmed.php
Original file line number Diff line number Diff line change
Expand Up @@ -91,7 +91,7 @@
'Controller' => ['HTTP', 'Validation'],
'Cookie' => ['I18n'],
'Database' => ['Entity', 'Events', 'I18n'],
'DataCaster' => ['I18n', 'URI', 'Database'],
'DataCaster' => ['I18n', 'URI', 'Database', 'Encryption'],
'DataConverter' => ['DataCaster'],
'Email' => ['I18n', 'Events'],
'Entity' => ['DataCaster', 'I18n'],
Expand Down
66 changes: 66 additions & 0 deletions system/DataCaster/Cast/EncryptedCast.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,66 @@
<?php

declare(strict_types=1);

/**
* This file is part of CodeIgniter 4 framework.
*
* (c) CodeIgniter Foundation <admin@codeigniter.com>
*
* For the full copyright and license information, please view
* the LICENSE file that was distributed with this source code.
*/

namespace CodeIgniter\DataCaster\Cast;

use CodeIgniter\DataCaster\Exceptions\CastException;
use SensitiveParameter;

/**
* Class EncryptedCast
*
* (PHP) [string --> encrypted string] --> (DB driver) --> (DB column) string
* [ <-- string ] <-- (DB driver) <-- (DB column) encrypted string
*/
class EncryptedCast extends BaseCast
{
public static function get(
#[SensitiveParameter]
mixed $value,
array $params = [],
?object $helper = null,
): ?string {
if ($value === null) {
return null;
}

if (! is_string($value)) {
throw CastException::forInvalidEncryptedValueType();
}

$decoded = base64_decode($value, true);

if ($decoded === false) {
throw CastException::forInvalidEncryptedPayload();
}

return service('encrypter')->decrypt($decoded);
}

public static function set(
#[SensitiveParameter]
mixed $value,
array $params = [],
?object $helper = null,
): ?string {
if ($value === null) {
return null;
}

if (! is_string($value)) {
throw CastException::forInvalidEncryptedValueType();
}

return base64_encode(service('encrypter')->encrypt($value));
}
}
2 changes: 2 additions & 0 deletions system/DataCaster/DataCaster.php
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,7 @@
use CodeIgniter\DataCaster\Cast\CastInterface;
use CodeIgniter\DataCaster\Cast\CSVCast;
use CodeIgniter\DataCaster\Cast\DatetimeCast;
use CodeIgniter\DataCaster\Cast\EncryptedCast;
use CodeIgniter\DataCaster\Cast\EnumCast;
use CodeIgniter\DataCaster\Cast\FloatCast;
use CodeIgniter\DataCaster\Cast\IntBoolCast;
Expand Down Expand Up @@ -55,6 +56,7 @@ final class DataCaster
'boolean' => BooleanCast::class,
'csv' => CSVCast::class,
'datetime' => DatetimeCast::class,
'encrypted' => EncryptedCast::class,
'enum' => EnumCast::class,
'double' => FloatCast::class,
'float' => FloatCast::class,
Expand Down
16 changes: 16 additions & 0 deletions system/Entity/Exceptions/CastException.php
Original file line number Diff line number Diff line change
Expand Up @@ -123,6 +123,22 @@ public static function forInvalidEnumType(string $expectedClass, string $actualC
return new static(lang('Cast.enumInvalidType', [$actualClass, $expectedClass]));
}

/**
* Thrown when an invalid type is provided for encrypted casting.
*/
public static function forInvalidEncryptedValueType(): static
{
return new static(lang('Cast.invalidEncryptedValueType'));
}

/**
* Thrown when an encrypted value is malformed.
*/
public static function forInvalidEncryptedPayload(): static
{
return new static(lang('Cast.invalidEncryptedPayload'));
}

/**
* Thrown when an invalid rounding mode is provided for float casting.
*/
Expand Down
32 changes: 17 additions & 15 deletions system/Language/en/Cast.php
Original file line number Diff line number Diff line change
Expand Up @@ -13,19 +13,21 @@

// Cast language settings
return [
'baseCastMissing' => 'The "{0}" class must inherit the "CodeIgniter\Entity\Cast\BaseCast" class.',
'enumInvalidCaseName' => 'Invalid case name "{0}" for enum "{1}".',
'enumInvalidType' => 'Expected enum of type "{1}", but received "{0}".',
'enumInvalidValue' => 'Invalid value "{1}" for enum "{0}".',
'enumMissingClass' => 'Enum class must be specified for enum casting.',
'enumNotEnum' => 'The "{0}" is not a valid enum class.',
'invalidCastMethod' => 'The "{0}" is invalid cast method, valid methods are: ["get", "set"].',
'invalidTimestamp' => 'Type casting "timestamp" expects a correct timestamp.',
'jsonErrorCtrlChar' => 'Unexpected control character found.',
'jsonErrorDepth' => 'Maximum stack depth exceeded.',
'jsonErrorStateMismatch' => 'Underflow or the modes mismatch.',
'jsonErrorSyntax' => 'Syntax error, malformed JSON.',
'jsonErrorUnknown' => 'Unknown error.',
'jsonErrorUtf8' => 'Malformed UTF-8 characters, possibly incorrectly encoded.',
'invalidFloatRoundingMode' => 'Invalid rounding mode "{0}" for float casting.',
'baseCastMissing' => 'The "{0}" class must inherit the "CodeIgniter\Entity\Cast\BaseCast" class.',
'enumInvalidCaseName' => 'Invalid case name "{0}" for enum "{1}".',
'enumInvalidType' => 'Expected enum of type "{1}", but received "{0}".',
'enumInvalidValue' => 'Invalid value "{1}" for enum "{0}".',
'enumMissingClass' => 'Enum class must be specified for enum casting.',
'enumNotEnum' => 'The "{0}" is not a valid enum class.',
'invalidCastMethod' => 'The "{0}" is invalid cast method, valid methods are: ["get", "set"].',
'invalidEncryptedPayload' => 'Type casting "encrypted" expects a valid encrypted value.',
'invalidEncryptedValueType' => 'Type casting "encrypted" expects a string or null value.',
'invalidTimestamp' => 'Type casting "timestamp" expects a correct timestamp.',
'jsonErrorCtrlChar' => 'Unexpected control character found.',
'jsonErrorDepth' => 'Maximum stack depth exceeded.',
'jsonErrorStateMismatch' => 'Underflow or the modes mismatch.',
'jsonErrorSyntax' => 'Syntax error, malformed JSON.',
'jsonErrorUnknown' => 'Unknown error.',
'jsonErrorUtf8' => 'Malformed UTF-8 characters, possibly incorrectly encoded.',
'invalidFloatRoundingMode' => 'Invalid rounding mode "{0}" for float casting.',
];
153 changes: 153 additions & 0 deletions tests/system/DataCaster/EncryptedCastTest.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,153 @@
<?php

declare(strict_types=1);

/**
* This file is part of CodeIgniter 4 framework.
*
* (c) CodeIgniter Foundation <admin@codeigniter.com>
*
* For the full copyright and license information, please view
* the LICENSE file that was distributed with this source code.
*/

namespace CodeIgniter\DataCaster;

use CodeIgniter\Config\Factories;
use CodeIgniter\DataCaster\Exceptions\CastException;
use CodeIgniter\DataConverter\DataConverter;
use CodeIgniter\Encryption\Exceptions\EncryptionException;
use CodeIgniter\Entity\Entity;
use CodeIgniter\Test\CIUnitTestCase;
use Config\Encryption as EncryptionConfig;
use Config\Services;
use PHPUnit\Framework\Attributes\Group;
use PHPUnit\Framework\Attributes\RequiresPhpExtension;

/**
* @internal
*/
#[Group('Others')]
#[RequiresPhpExtension('openssl')]
final class EncryptedCastTest extends CIUnitTestCase
{
private const CURRENT_KEY = 'current-encrypted-cast-key';
private const OLD_KEY = 'old-encrypted-cast-key';

protected function setUp(): void
{
parent::setUp();

$this->useEncryptionKey(self::CURRENT_KEY);
}

public function testSetEncryptsStringAsEncodedText(): void
{
$dataCaster = new DataCaster(types: ['secret' => 'encrypted']);

$encrypted = $dataCaster->castAs('plain-secret', 'secret', 'set');

$this->assertIsString($encrypted);
$this->assertNotSame('plain-secret', $encrypted);
$this->assertNotFalse(base64_decode($encrypted, true));
$this->assertSame('plain-secret', $dataCaster->castAs($encrypted, 'secret'));
}

public function testEncryptedCastSupportsNullableValues(): void
{
$dataCaster = new DataCaster(types: ['secret' => '?encrypted']);

$this->assertNull($dataCaster->castAs(null, 'secret', 'set'));
$this->assertNull($dataCaster->castAs(null, 'secret'));
}

public function testEncryptedCastRejectsInvalidPlainValueWithoutLeakingIt(): void
{
$dataCaster = new DataCaster(types: ['secret' => 'encrypted']);

try {
$dataCaster->castAs(['token' => 'sensitive-value'], 'secret', 'set');
} catch (CastException $e) {
$this->assertSame('Type casting "encrypted" expects a string or null value.', $e->getMessage());
$this->assertStringNotContainsString('token', $e->getMessage());
$this->assertStringNotContainsString('sensitive-value', $e->getMessage());

return;
}

$this->fail('Expected encrypted casting to reject non-string values.');
}

public function testEncryptedCastRejectsMalformedPayload(): void
{
$this->expectException(CastException::class);
$this->expectExceptionMessage('Type casting "encrypted" expects a valid encrypted value.');

$dataCaster = new DataCaster(types: ['secret' => 'encrypted']);

$dataCaster->castAs('@@not-base64@@', 'secret');
}

public function testEncryptedCastBubblesAuthenticationFailures(): void
{
$this->expectException(EncryptionException::class);

$dataCaster = new DataCaster(types: ['secret' => 'encrypted']);

$dataCaster->castAs(base64_encode('not-encrypted'), 'secret');
}

public function testEncryptedCastCanDecryptPreviousKeyValues(): void
{
$this->useEncryptionKey(self::OLD_KEY);
$oldEncryptedValue = base64_encode(Services::encrypter()->encrypt('old-secret'));

$this->useEncryptionKey(self::CURRENT_KEY, [self::OLD_KEY]);

$dataCaster = new DataCaster(types: ['secret' => 'encrypted']);

$this->assertSame('old-secret', $dataCaster->castAs($oldEncryptedValue, 'secret'));
}

public function testDataConverterConvertsEncryptedFieldToAndFromDataSource(): void
{
$converter = new DataConverter(['secret' => 'encrypted']);

$dataSourceData = $converter->toDataSource(['secret' => 'plain-secret']);

$this->assertIsString($dataSourceData['secret']);
$this->assertNotSame('plain-secret', $dataSourceData['secret']);
$this->assertSame(['secret' => 'plain-secret'], $converter->fromDataSource($dataSourceData));
}

public function testEntityStoresEncryptedRawValueAndReturnsPlaintext(): void
{
$entity = new class () extends Entity {
protected $casts = [
'secret' => 'encrypted',
];
};

$entity->secret = 'plain-secret';

$raw = $entity->toRawArray();

$this->assertIsString($raw['secret']);
$this->assertNotSame('plain-secret', $raw['secret']);
$this->assertSame('plain-secret', $entity->secret);
$this->assertSame(['secret' => 'plain-secret'], $entity->toArray());
}

/**
* @param list<string> $previousKeys
*/
private function useEncryptionKey(string $key, array $previousKeys = []): void
{
$config = new EncryptionConfig();
$config->driver = 'OpenSSL';
$config->key = $key;
$config->previousKeys = $previousKeys;

Factories::injectMock('config', EncryptionConfig::class, $config);
}
}
1 change: 1 addition & 0 deletions user_guide_src/source/changelogs/v4.8.0.rst
Original file line number Diff line number Diff line change
Expand Up @@ -273,6 +273,7 @@ Model
=====

- Added new ``chunkRows()`` method to ``CodeIgniter\Model`` for processing large datasets in smaller chunks.
- Added ``encrypted`` casting for Entity properties and Model fields using the Encryption service. See :ref:`model-field-casting-encrypted`.
- Added new ``firstOrInsert()`` method to ``CodeIgniter\Model`` that finds the first row matching the given attributes or inserts a new one. See :ref:`model-first-or-insert`.
- Added ``$throwOnDisallowedFields`` and ``throwOnDisallowedFields()`` to ``CodeIgniter\Model`` to throw a ``DataException`` when write data contains fields that would otherwise be discarded by ``$allowedFields``. See :ref:`model-throw-on-disallowed-fields`.

Expand Down
44 changes: 43 additions & 1 deletion user_guide_src/source/models/entities.rst
Original file line number Diff line number Diff line change
Expand Up @@ -256,11 +256,12 @@ Scalar Type Casting
-------------------

Properties can be cast to any of the following data types:
**integer**, **float**, **double**, **string**, **boolean**, **object**, **array**, **datetime**, **timestamp**, **uri**, **int-bool** and **enum**.
**integer**, **float**, **double**, **string**, **boolean**, **object**, **array**, **datetime**, **timestamp**, **uri**, **int-bool**, **enum** and **encrypted**.
Add a question mark at the beginning of type to mark property as nullable, i.e., **?string**, **?integer**.

.. note:: **int-bool** can be used since v4.3.0.
.. note:: **enum** can be used since v4.7.0.
.. note:: **encrypted** can be used since v4.8.0.
.. note:: Since v4.8.0, you can also pass parameters to **float** and **double** types to specify the number of decimal places and rounding mode, i.e., **float[2,even]**.

For example, if you had a User entity with an ``is_banned`` property, you can cast it as a boolean:
Expand Down Expand Up @@ -332,6 +333,47 @@ For nullable enums:

.. literalinclude:: entities/027.php

.. _entities-encrypted-casting:

Encrypted Casting
-----------------

.. versionadded:: 4.8.0

Encrypted casting encrypts string values when they are set and decrypts them
when they are read. It uses the :doc:`Encryption </libraries/encryption>`
service, so you must configure an encryption key before using it. The
configured key is required for both writing new values and reading stored
values back. The ``encrypted`` type accepts string values. Use ``?encrypted``
for nullable values.

.. literalinclude:: entities/029.php

Encrypted values are stored as Base64-encoded ciphertext. Use a ``TEXT`` column
or a sufficiently large string column because the stored value is longer than
the plain text value. Avoid narrow columns like ``VARCHAR(255)`` unless you have
verified the maximum encrypted length for the values you will store.

.. warning:: Encrypted values cannot be searched, sorted, filtered, or checked
for uniqueness by their plain text value in the database.

.. warning:: Do not use encrypted casting for passwords. Passwords should be
hashed with PHP's password hashing functions.

.. note:: ``toArray()`` and JSON serialization return decrypted values. Use
``toRawArray()`` when you need the encrypted value that will be stored.

.. note:: If the stored value cannot be decrypted, an ``EncryptionException`` is
thrown.

.. note:: Encrypted casts follow the Encryption service's key rotation behavior.
Values encrypted with a previous key can be decrypted when that key is
configured in ``previousKeys``. Save the value again to re-encrypt it with
the current key. See :ref:`spark-key-rotate` for rotating keys.

.. note:: Encryption is non-deterministic. Setting the same plain text value can
produce a different encrypted value and mark the Entity attribute as changed.

Custom Casting
--------------

Expand Down
22 changes: 22 additions & 0 deletions user_guide_src/source/models/entities/029.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,22 @@
<?php

namespace App\Entities;

use CodeIgniter\Entity\Entity;

class User extends Entity
{
protected $casts = [
'secret_note' => 'encrypted',
];
}

$user = new User();

$user->secret_note = 'Internal billing note';

echo $user->secret_note; // Internal billing note

$raw = $user->toRawArray();

echo $raw['secret_note']; // Base64-encoded encrypted value
Loading
Loading