Skip to content

Commit 18ee79c

Browse files
authored
feat: add encrypted casts for model fields and entities (#10357)
* 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> * fix: cs Signed-off-by: memleakd <121398829+memleakd@users.noreply.github.com> * refactor: use service helper for encrypted cast Signed-off-by: memleakd <121398829+memleakd@users.noreply.github.com> --------- Signed-off-by: memleakd <121398829+memleakd@users.noreply.github.com>
1 parent ff60c9a commit 18ee79c

11 files changed

Lines changed: 389 additions & 17 deletions

File tree

structarmed.php

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -91,7 +91,7 @@
9191
'Controller' => ['HTTP', 'Validation'],
9292
'Cookie' => ['I18n'],
9393
'Database' => ['Entity', 'Events', 'I18n'],
94-
'DataCaster' => ['I18n', 'URI', 'Database'],
94+
'DataCaster' => ['I18n', 'URI', 'Database', 'Encryption'],
9595
'DataConverter' => ['DataCaster'],
9696
'Email' => ['I18n', 'Events'],
9797
'Entity' => ['DataCaster', 'I18n'],
Lines changed: 66 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,66 @@
1+
<?php
2+
3+
declare(strict_types=1);
4+
5+
/**
6+
* This file is part of CodeIgniter 4 framework.
7+
*
8+
* (c) CodeIgniter Foundation <admin@codeigniter.com>
9+
*
10+
* For the full copyright and license information, please view
11+
* the LICENSE file that was distributed with this source code.
12+
*/
13+
14+
namespace CodeIgniter\DataCaster\Cast;
15+
16+
use CodeIgniter\DataCaster\Exceptions\CastException;
17+
use SensitiveParameter;
18+
19+
/**
20+
* Class EncryptedCast
21+
*
22+
* (PHP) [string --> encrypted string] --> (DB driver) --> (DB column) string
23+
* [ <-- string ] <-- (DB driver) <-- (DB column) encrypted string
24+
*/
25+
class EncryptedCast extends BaseCast
26+
{
27+
public static function get(
28+
#[SensitiveParameter]
29+
mixed $value,
30+
array $params = [],
31+
?object $helper = null,
32+
): ?string {
33+
if ($value === null) {
34+
return null;
35+
}
36+
37+
if (! is_string($value)) {
38+
throw CastException::forInvalidEncryptedValueType();
39+
}
40+
41+
$decoded = base64_decode($value, true);
42+
43+
if ($decoded === false) {
44+
throw CastException::forInvalidEncryptedPayload();
45+
}
46+
47+
return service('encrypter')->decrypt($decoded);
48+
}
49+
50+
public static function set(
51+
#[SensitiveParameter]
52+
mixed $value,
53+
array $params = [],
54+
?object $helper = null,
55+
): ?string {
56+
if ($value === null) {
57+
return null;
58+
}
59+
60+
if (! is_string($value)) {
61+
throw CastException::forInvalidEncryptedValueType();
62+
}
63+
64+
return base64_encode(service('encrypter')->encrypt($value));
65+
}
66+
}

system/DataCaster/DataCaster.php

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -18,6 +18,7 @@
1818
use CodeIgniter\DataCaster\Cast\CastInterface;
1919
use CodeIgniter\DataCaster\Cast\CSVCast;
2020
use CodeIgniter\DataCaster\Cast\DatetimeCast;
21+
use CodeIgniter\DataCaster\Cast\EncryptedCast;
2122
use CodeIgniter\DataCaster\Cast\EnumCast;
2223
use CodeIgniter\DataCaster\Cast\FloatCast;
2324
use CodeIgniter\DataCaster\Cast\IntBoolCast;
@@ -55,6 +56,7 @@ final class DataCaster
5556
'boolean' => BooleanCast::class,
5657
'csv' => CSVCast::class,
5758
'datetime' => DatetimeCast::class,
59+
'encrypted' => EncryptedCast::class,
5860
'enum' => EnumCast::class,
5961
'double' => FloatCast::class,
6062
'float' => FloatCast::class,

system/Entity/Exceptions/CastException.php

Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -123,6 +123,22 @@ public static function forInvalidEnumType(string $expectedClass, string $actualC
123123
return new static(lang('Cast.enumInvalidType', [$actualClass, $expectedClass]));
124124
}
125125

126+
/**
127+
* Thrown when an invalid type is provided for encrypted casting.
128+
*/
129+
public static function forInvalidEncryptedValueType(): static
130+
{
131+
return new static(lang('Cast.invalidEncryptedValueType'));
132+
}
133+
134+
/**
135+
* Thrown when an encrypted value is malformed.
136+
*/
137+
public static function forInvalidEncryptedPayload(): static
138+
{
139+
return new static(lang('Cast.invalidEncryptedPayload'));
140+
}
141+
126142
/**
127143
* Thrown when an invalid rounding mode is provided for float casting.
128144
*/

system/Language/en/Cast.php

Lines changed: 17 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -13,19 +13,21 @@
1313

1414
// Cast language settings
1515
return [
16-
'baseCastMissing' => 'The "{0}" class must inherit the "CodeIgniter\Entity\Cast\BaseCast" class.',
17-
'enumInvalidCaseName' => 'Invalid case name "{0}" for enum "{1}".',
18-
'enumInvalidType' => 'Expected enum of type "{1}", but received "{0}".',
19-
'enumInvalidValue' => 'Invalid value "{1}" for enum "{0}".',
20-
'enumMissingClass' => 'Enum class must be specified for enum casting.',
21-
'enumNotEnum' => 'The "{0}" is not a valid enum class.',
22-
'invalidCastMethod' => 'The "{0}" is invalid cast method, valid methods are: ["get", "set"].',
23-
'invalidTimestamp' => 'Type casting "timestamp" expects a correct timestamp.',
24-
'jsonErrorCtrlChar' => 'Unexpected control character found.',
25-
'jsonErrorDepth' => 'Maximum stack depth exceeded.',
26-
'jsonErrorStateMismatch' => 'Underflow or the modes mismatch.',
27-
'jsonErrorSyntax' => 'Syntax error, malformed JSON.',
28-
'jsonErrorUnknown' => 'Unknown error.',
29-
'jsonErrorUtf8' => 'Malformed UTF-8 characters, possibly incorrectly encoded.',
30-
'invalidFloatRoundingMode' => 'Invalid rounding mode "{0}" for float casting.',
16+
'baseCastMissing' => 'The "{0}" class must inherit the "CodeIgniter\Entity\Cast\BaseCast" class.',
17+
'enumInvalidCaseName' => 'Invalid case name "{0}" for enum "{1}".',
18+
'enumInvalidType' => 'Expected enum of type "{1}", but received "{0}".',
19+
'enumInvalidValue' => 'Invalid value "{1}" for enum "{0}".',
20+
'enumMissingClass' => 'Enum class must be specified for enum casting.',
21+
'enumNotEnum' => 'The "{0}" is not a valid enum class.',
22+
'invalidCastMethod' => 'The "{0}" is invalid cast method, valid methods are: ["get", "set"].',
23+
'invalidEncryptedPayload' => 'Type casting "encrypted" expects a valid encrypted value.',
24+
'invalidEncryptedValueType' => 'Type casting "encrypted" expects a string or null value.',
25+
'invalidTimestamp' => 'Type casting "timestamp" expects a correct timestamp.',
26+
'jsonErrorCtrlChar' => 'Unexpected control character found.',
27+
'jsonErrorDepth' => 'Maximum stack depth exceeded.',
28+
'jsonErrorStateMismatch' => 'Underflow or the modes mismatch.',
29+
'jsonErrorSyntax' => 'Syntax error, malformed JSON.',
30+
'jsonErrorUnknown' => 'Unknown error.',
31+
'jsonErrorUtf8' => 'Malformed UTF-8 characters, possibly incorrectly encoded.',
32+
'invalidFloatRoundingMode' => 'Invalid rounding mode "{0}" for float casting.',
3133
];
Lines changed: 153 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,153 @@
1+
<?php
2+
3+
declare(strict_types=1);
4+
5+
/**
6+
* This file is part of CodeIgniter 4 framework.
7+
*
8+
* (c) CodeIgniter Foundation <admin@codeigniter.com>
9+
*
10+
* For the full copyright and license information, please view
11+
* the LICENSE file that was distributed with this source code.
12+
*/
13+
14+
namespace CodeIgniter\DataCaster;
15+
16+
use CodeIgniter\Config\Factories;
17+
use CodeIgniter\DataCaster\Exceptions\CastException;
18+
use CodeIgniter\DataConverter\DataConverter;
19+
use CodeIgniter\Encryption\Exceptions\EncryptionException;
20+
use CodeIgniter\Entity\Entity;
21+
use CodeIgniter\Test\CIUnitTestCase;
22+
use Config\Encryption as EncryptionConfig;
23+
use Config\Services;
24+
use PHPUnit\Framework\Attributes\Group;
25+
use PHPUnit\Framework\Attributes\RequiresPhpExtension;
26+
27+
/**
28+
* @internal
29+
*/
30+
#[Group('Others')]
31+
#[RequiresPhpExtension('openssl')]
32+
final class EncryptedCastTest extends CIUnitTestCase
33+
{
34+
private const CURRENT_KEY = 'current-encrypted-cast-key';
35+
private const OLD_KEY = 'old-encrypted-cast-key';
36+
37+
protected function setUp(): void
38+
{
39+
parent::setUp();
40+
41+
$this->useEncryptionKey(self::CURRENT_KEY);
42+
}
43+
44+
public function testSetEncryptsStringAsEncodedText(): void
45+
{
46+
$dataCaster = new DataCaster(types: ['secret' => 'encrypted']);
47+
48+
$encrypted = $dataCaster->castAs('plain-secret', 'secret', 'set');
49+
50+
$this->assertIsString($encrypted);
51+
$this->assertNotSame('plain-secret', $encrypted);
52+
$this->assertNotFalse(base64_decode($encrypted, true));
53+
$this->assertSame('plain-secret', $dataCaster->castAs($encrypted, 'secret'));
54+
}
55+
56+
public function testEncryptedCastSupportsNullableValues(): void
57+
{
58+
$dataCaster = new DataCaster(types: ['secret' => '?encrypted']);
59+
60+
$this->assertNull($dataCaster->castAs(null, 'secret', 'set'));
61+
$this->assertNull($dataCaster->castAs(null, 'secret'));
62+
}
63+
64+
public function testEncryptedCastRejectsInvalidPlainValueWithoutLeakingIt(): void
65+
{
66+
$dataCaster = new DataCaster(types: ['secret' => 'encrypted']);
67+
68+
try {
69+
$dataCaster->castAs(['token' => 'sensitive-value'], 'secret', 'set');
70+
} catch (CastException $e) {
71+
$this->assertSame('Type casting "encrypted" expects a string or null value.', $e->getMessage());
72+
$this->assertStringNotContainsString('token', $e->getMessage());
73+
$this->assertStringNotContainsString('sensitive-value', $e->getMessage());
74+
75+
return;
76+
}
77+
78+
$this->fail('Expected encrypted casting to reject non-string values.');
79+
}
80+
81+
public function testEncryptedCastRejectsMalformedPayload(): void
82+
{
83+
$this->expectException(CastException::class);
84+
$this->expectExceptionMessage('Type casting "encrypted" expects a valid encrypted value.');
85+
86+
$dataCaster = new DataCaster(types: ['secret' => 'encrypted']);
87+
88+
$dataCaster->castAs('@@not-base64@@', 'secret');
89+
}
90+
91+
public function testEncryptedCastBubblesAuthenticationFailures(): void
92+
{
93+
$this->expectException(EncryptionException::class);
94+
95+
$dataCaster = new DataCaster(types: ['secret' => 'encrypted']);
96+
97+
$dataCaster->castAs(base64_encode('not-encrypted'), 'secret');
98+
}
99+
100+
public function testEncryptedCastCanDecryptPreviousKeyValues(): void
101+
{
102+
$this->useEncryptionKey(self::OLD_KEY);
103+
$oldEncryptedValue = base64_encode(Services::encrypter()->encrypt('old-secret'));
104+
105+
$this->useEncryptionKey(self::CURRENT_KEY, [self::OLD_KEY]);
106+
107+
$dataCaster = new DataCaster(types: ['secret' => 'encrypted']);
108+
109+
$this->assertSame('old-secret', $dataCaster->castAs($oldEncryptedValue, 'secret'));
110+
}
111+
112+
public function testDataConverterConvertsEncryptedFieldToAndFromDataSource(): void
113+
{
114+
$converter = new DataConverter(['secret' => 'encrypted']);
115+
116+
$dataSourceData = $converter->toDataSource(['secret' => 'plain-secret']);
117+
118+
$this->assertIsString($dataSourceData['secret']);
119+
$this->assertNotSame('plain-secret', $dataSourceData['secret']);
120+
$this->assertSame(['secret' => 'plain-secret'], $converter->fromDataSource($dataSourceData));
121+
}
122+
123+
public function testEntityStoresEncryptedRawValueAndReturnsPlaintext(): void
124+
{
125+
$entity = new class () extends Entity {
126+
protected $casts = [
127+
'secret' => 'encrypted',
128+
];
129+
};
130+
131+
$entity->secret = 'plain-secret';
132+
133+
$raw = $entity->toRawArray();
134+
135+
$this->assertIsString($raw['secret']);
136+
$this->assertNotSame('plain-secret', $raw['secret']);
137+
$this->assertSame('plain-secret', $entity->secret);
138+
$this->assertSame(['secret' => 'plain-secret'], $entity->toArray());
139+
}
140+
141+
/**
142+
* @param list<string> $previousKeys
143+
*/
144+
private function useEncryptionKey(string $key, array $previousKeys = []): void
145+
{
146+
$config = new EncryptionConfig();
147+
$config->driver = 'OpenSSL';
148+
$config->key = $key;
149+
$config->previousKeys = $previousKeys;
150+
151+
Factories::injectMock('config', EncryptionConfig::class, $config);
152+
}
153+
}

user_guide_src/source/changelogs/v4.8.0.rst

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -274,6 +274,7 @@ Model
274274
=====
275275

276276
- Added new ``chunkRows()`` method to ``CodeIgniter\Model`` for processing large datasets in smaller chunks.
277+
- Added ``encrypted`` casting for Entity properties and Model fields using the Encryption service. See :ref:`model-field-casting-encrypted`.
277278
- 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`.
278279
- Added ``$insertOnlyFields`` and ``setInsertOnlyFields()`` to ``CodeIgniter\Model`` to remove configured fields from Model update operations while allowing them during inserts. See :ref:`model-insert-only-fields`.
279280
- 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`.

user_guide_src/source/models/entities.rst

Lines changed: 43 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -256,11 +256,12 @@ Scalar Type Casting
256256
-------------------
257257

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

262262
.. note:: **int-bool** can be used since v4.3.0.
263263
.. note:: **enum** can be used since v4.7.0.
264+
.. note:: **encrypted** can be used since v4.8.0.
264265
.. 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]**.
265266

266267
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:
332333

333334
.. literalinclude:: entities/027.php
334335

336+
.. _entities-encrypted-casting:
337+
338+
Encrypted Casting
339+
-----------------
340+
341+
.. versionadded:: 4.8.0
342+
343+
Encrypted casting encrypts string values when they are set and decrypts them
344+
when they are read. It uses the :doc:`Encryption </libraries/encryption>`
345+
service, so you must configure an encryption key before using it. The
346+
configured key is required for both writing new values and reading stored
347+
values back. The ``encrypted`` type accepts string values. Use ``?encrypted``
348+
for nullable values.
349+
350+
.. literalinclude:: entities/029.php
351+
352+
Encrypted values are stored as Base64-encoded ciphertext. Use a ``TEXT`` column
353+
or a sufficiently large string column because the stored value is longer than
354+
the plain text value. Avoid narrow columns like ``VARCHAR(255)`` unless you have
355+
verified the maximum encrypted length for the values you will store.
356+
357+
.. warning:: Encrypted values cannot be searched, sorted, filtered, or checked
358+
for uniqueness by their plain text value in the database.
359+
360+
.. warning:: Do not use encrypted casting for passwords. Passwords should be
361+
hashed with PHP's password hashing functions.
362+
363+
.. note:: ``toArray()`` and JSON serialization return decrypted values. Use
364+
``toRawArray()`` when you need the encrypted value that will be stored.
365+
366+
.. note:: If the stored value cannot be decrypted, an ``EncryptionException`` is
367+
thrown.
368+
369+
.. note:: Encrypted casts follow the Encryption service's key rotation behavior.
370+
Values encrypted with a previous key can be decrypted when that key is
371+
configured in ``previousKeys``. Save the value again to re-encrypt it with
372+
the current key. See :ref:`spark-key-rotate` for rotating keys.
373+
374+
.. note:: Encryption is non-deterministic. Setting the same plain text value can
375+
produce a different encrypted value and mark the Entity attribute as changed.
376+
335377
Custom Casting
336378
--------------
337379

Lines changed: 22 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,22 @@
1+
<?php
2+
3+
namespace App\Entities;
4+
5+
use CodeIgniter\Entity\Entity;
6+
7+
class User extends Entity
8+
{
9+
protected $casts = [
10+
'secret_note' => 'encrypted',
11+
];
12+
}
13+
14+
$user = new User();
15+
16+
$user->secret_note = 'Internal billing note';
17+
18+
echo $user->secret_note; // Internal billing note
19+
20+
$raw = $user->toRawArray();
21+
22+
echo $raw['secret_note']; // Base64-encoded encrypted value

0 commit comments

Comments
 (0)