From 383522d6620910cdf76e8371cd0f62d6a7f215d2 Mon Sep 17 00:00:00 2001 From: memleakd <121398829+memleakd@users.noreply.github.com> Date: Tue, 30 Jun 2026 15:19:50 +0300 Subject: [PATCH 1/3] feat(model): add encrypted casting - Add encrypted DataCaster support for Model fields and Entity properties. - Store encrypted values as Base64-encoded ciphertext using the Encryption service. - Document storage, validation, query, serialization, and key rotation caveats. - Add focused tests for encryption, decryption, nullable values, previous keys, and invalid payloads. Signed-off-by: memleakd <121398829+memleakd@users.noreply.github.com> --- structarmed.php | 2 +- system/DataCaster/Cast/EncryptedCast.php | 67 ++++++++ system/DataCaster/DataCaster.php | 2 + system/Entity/Exceptions/CastException.php | 16 ++ system/Language/en/Cast.php | 32 ++-- tests/system/DataCaster/EncryptedCastTest.php | 153 ++++++++++++++++++ user_guide_src/source/changelogs/v4.8.0.rst | 1 + user_guide_src/source/models/entities.rst | 44 ++++- user_guide_src/source/models/entities/029.php | 21 +++ user_guide_src/source/models/model.rst | 42 +++++ user_guide_src/source/models/model/069.php | 26 +++ 11 files changed, 389 insertions(+), 17 deletions(-) create mode 100644 system/DataCaster/Cast/EncryptedCast.php create mode 100644 tests/system/DataCaster/EncryptedCastTest.php create mode 100644 user_guide_src/source/models/entities/029.php create mode 100644 user_guide_src/source/models/model/069.php diff --git a/structarmed.php b/structarmed.php index 7665bd34afd3..d5bc6b9b07a5 100644 --- a/structarmed.php +++ b/structarmed.php @@ -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'], diff --git a/system/DataCaster/Cast/EncryptedCast.php b/system/DataCaster/Cast/EncryptedCast.php new file mode 100644 index 000000000000..51df83e28e02 --- /dev/null +++ b/system/DataCaster/Cast/EncryptedCast.php @@ -0,0 +1,67 @@ + + * + * 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 Config\Services; +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 Services::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(Services::encrypter()->encrypt($value)); + } +} diff --git a/system/DataCaster/DataCaster.php b/system/DataCaster/DataCaster.php index e19e42bb7f0c..8e4669d6bed2 100644 --- a/system/DataCaster/DataCaster.php +++ b/system/DataCaster/DataCaster.php @@ -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; @@ -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, diff --git a/system/Entity/Exceptions/CastException.php b/system/Entity/Exceptions/CastException.php index 252eb0c42df2..db82ca40bb3f 100644 --- a/system/Entity/Exceptions/CastException.php +++ b/system/Entity/Exceptions/CastException.php @@ -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. */ diff --git a/system/Language/en/Cast.php b/system/Language/en/Cast.php index d4762634ca81..d55957924e35 100644 --- a/system/Language/en/Cast.php +++ b/system/Language/en/Cast.php @@ -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.', ]; diff --git a/tests/system/DataCaster/EncryptedCastTest.php b/tests/system/DataCaster/EncryptedCastTest.php new file mode 100644 index 000000000000..44e0dde8d84a --- /dev/null +++ b/tests/system/DataCaster/EncryptedCastTest.php @@ -0,0 +1,153 @@ + + * + * 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 $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); + } +} diff --git a/user_guide_src/source/changelogs/v4.8.0.rst b/user_guide_src/source/changelogs/v4.8.0.rst index e145c14cacbc..0bacf656df26 100644 --- a/user_guide_src/source/changelogs/v4.8.0.rst +++ b/user_guide_src/source/changelogs/v4.8.0.rst @@ -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`. diff --git a/user_guide_src/source/models/entities.rst b/user_guide_src/source/models/entities.rst index f31c2981ba87..de88e34a0fb2 100644 --- a/user_guide_src/source/models/entities.rst +++ b/user_guide_src/source/models/entities.rst @@ -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: @@ -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 ` +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 -------------- diff --git a/user_guide_src/source/models/entities/029.php b/user_guide_src/source/models/entities/029.php new file mode 100644 index 000000000000..02a785d36363 --- /dev/null +++ b/user_guide_src/source/models/entities/029.php @@ -0,0 +1,21 @@ + '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 diff --git a/user_guide_src/source/models/model.rst b/user_guide_src/source/models/model.rst index 648d5b9c9b01..025a8daa8d10 100644 --- a/user_guide_src/source/models/model.rst +++ b/user_guide_src/source/models/model.rst @@ -396,6 +396,8 @@ of type to mark the field as nullable, i.e., ``?int``, ``?datetime``. +---------------+----------------+---------------------------+ |``enum`` | Enum | string/int type | +---------------+----------------+---------------------------+ +|``encrypted`` | string | string/text type | ++---------------+----------------+---------------------------+ float ----- @@ -459,6 +461,46 @@ Enum casting supports: * **Backed enums** (string or int) - The backing value is stored in the database * **Unit enums** - The case name is stored in the database as a string +.. _model-field-casting-encrypted: + +encrypted +--------- + +.. versionadded:: 4.8.0 + +Casting as ``encrypted`` encrypts string values before they are stored and +decrypts them when they are retrieved. It uses the +:doc:`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:: model/069.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:: Model validation and write callbacks receive the encrypted value + because Model Field Casting converts values before they are stored. Validate + the plain text value before passing it to the Model when validation must + inspect the plain text. + +.. note:: If the stored value cannot be decrypted, an ``EncryptionException`` is + thrown. + +.. note:: If you rotate encryption keys, 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. + Custom Casting ============== diff --git a/user_guide_src/source/models/model/069.php b/user_guide_src/source/models/model/069.php new file mode 100644 index 000000000000..a40334c5574b --- /dev/null +++ b/user_guide_src/source/models/model/069.php @@ -0,0 +1,26 @@ + 'encrypted', + ]; +} + +$userModel = model(UserModel::class); + +$id = $userModel->insert([ + 'secret_note' => 'Internal billing note', +]); + +$user = $userModel->find($id); + +echo $user['secret_note']; // Internal billing note From 568b2e800816e9daa6046c339be4590efa796c6d Mon Sep 17 00:00:00 2001 From: memleakd <121398829+memleakd@users.noreply.github.com> Date: Tue, 30 Jun 2026 15:30:11 +0300 Subject: [PATCH 2/3] fix: cs Signed-off-by: memleakd <121398829+memleakd@users.noreply.github.com> --- user_guide_src/source/models/entities/029.php | 1 + 1 file changed, 1 insertion(+) diff --git a/user_guide_src/source/models/entities/029.php b/user_guide_src/source/models/entities/029.php index 02a785d36363..d19c6a8160b2 100644 --- a/user_guide_src/source/models/entities/029.php +++ b/user_guide_src/source/models/entities/029.php @@ -12,6 +12,7 @@ class User extends Entity } $user = new User(); + $user->secret_note = 'Internal billing note'; echo $user->secret_note; // Internal billing note From a42b7793847a2d896bcf277724e538c1adbae706 Mon Sep 17 00:00:00 2001 From: memleakd <121398829+memleakd@users.noreply.github.com> Date: Wed, 1 Jul 2026 11:49:57 +0300 Subject: [PATCH 3/3] refactor: use service helper for encrypted cast Signed-off-by: memleakd <121398829+memleakd@users.noreply.github.com> --- system/DataCaster/Cast/EncryptedCast.php | 5 ++--- 1 file changed, 2 insertions(+), 3 deletions(-) diff --git a/system/DataCaster/Cast/EncryptedCast.php b/system/DataCaster/Cast/EncryptedCast.php index 51df83e28e02..ee0c9c12d116 100644 --- a/system/DataCaster/Cast/EncryptedCast.php +++ b/system/DataCaster/Cast/EncryptedCast.php @@ -14,7 +14,6 @@ namespace CodeIgniter\DataCaster\Cast; use CodeIgniter\DataCaster\Exceptions\CastException; -use Config\Services; use SensitiveParameter; /** @@ -45,7 +44,7 @@ public static function get( throw CastException::forInvalidEncryptedPayload(); } - return Services::encrypter()->decrypt($decoded); + return service('encrypter')->decrypt($decoded); } public static function set( @@ -62,6 +61,6 @@ public static function set( throw CastException::forInvalidEncryptedValueType(); } - return base64_encode(Services::encrypter()->encrypt($value)); + return base64_encode(service('encrypter')->encrypt($value)); } }