Skip to content

Commit b9306c3

Browse files
authored
Merge pull request #74 from patchlevel/crypto-field-prefix
add field prefix for encrypted fields
2 parents 4990a46 + 56c3279 commit b9306c3

File tree

4 files changed

+99
-8
lines changed

4 files changed

+99
-8
lines changed

baseline.xml

Lines changed: 4 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,5 @@
11
<?xml version="1.0" encoding="UTF-8"?>
2-
<files psalm-version="5.26.1@d747f6500b38ac4f7dfc5edbcae6e4b637d7add0">
2+
<files psalm-version="6.9.1@81c8a77c0793d450fee40265cfe68891df11d505">
33
<file src="src/Cryptography/Cipher/OpensslCipherKeyFactory.php">
44
<ArgumentTypeCoercion>
55
<code><![CDATA[openssl_random_pseudo_bytes($this->ivLength)]]></code>
@@ -14,12 +14,14 @@
1414
</file>
1515
<file src="src/Cryptography/PersonalDataPayloadCryptographer.php">
1616
<MixedArgument>
17-
<code><![CDATA[$data[$propertyMetadata->fieldName()]]]></code>
17+
<code><![CDATA[$rawData]]></code>
1818
</MixedArgument>
1919
<MixedAssignment>
2020
<code><![CDATA[$data[$propertyMetadata->fieldName()]]]></code>
2121
<code><![CDATA[$data[$propertyMetadata->fieldName()]]]></code>
2222
<code><![CDATA[$data[$propertyMetadata->fieldName()]]]></code>
23+
<code><![CDATA[$rawData]]></code>
24+
<code><![CDATA[$rawData]]></code>
2325
</MixedAssignment>
2426
</file>
2527
<file src="src/Metadata/AttributeMetadataFactory.php">

src/Cryptography/PersonalDataPayloadCryptographer.php

Lines changed: 14 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -23,6 +23,7 @@ public function __construct(
2323
private readonly CipherKeyStore $cipherKeyStore,
2424
private readonly CipherKeyFactory $cipherKeyFactory,
2525
private readonly Cipher $cipher,
26+
private readonly bool $fallbackWithoutPrefix = true,
2627
) {
2728
}
2829

@@ -51,10 +52,12 @@ public function encrypt(ClassMetadata $metadata, array $data): array
5152
continue;
5253
}
5354

54-
$data[$propertyMetadata->fieldName()] = $this->cipher->encrypt(
55+
$data[$propertyMetadata->encryptedFieldName()] = $this->cipher->encrypt(
5556
$cipherKey,
5657
$data[$propertyMetadata->fieldName()],
5758
);
59+
60+
unset($data[$propertyMetadata->fieldName()]);
5861
}
5962

6063
return $data;
@@ -84,6 +87,15 @@ public function decrypt(ClassMetadata $metadata, array $data): array
8487
continue;
8588
}
8689

90+
if (array_key_exists($propertyMetadata->encryptedFieldName(), $data)) {
91+
$rawData = $data[$propertyMetadata->encryptedFieldName()];
92+
unset($data[$propertyMetadata->encryptedFieldName()]);
93+
} elseif ($this->fallbackWithoutPrefix) {
94+
$rawData = $data[$propertyMetadata->fieldName()];
95+
} else {
96+
continue;
97+
}
98+
8799
if (!$cipherKey) {
88100
$data[$propertyMetadata->fieldName()] = $propertyMetadata->personalDataFallback();
89101
continue;
@@ -92,7 +104,7 @@ public function decrypt(ClassMetadata $metadata, array $data): array
92104
try {
93105
$data[$propertyMetadata->fieldName()] = $this->cipher->decrypt(
94106
$cipherKey,
95-
$data[$propertyMetadata->fieldName()],
107+
$rawData,
96108
);
97109
} catch (DecryptionFailed) {
98110
$data[$propertyMetadata->fieldName()] = $propertyMetadata->personalDataFallback();

src/Metadata/PropertyMetadata.php

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4,9 +4,12 @@
44

55
namespace Patchlevel\Hydrator\Metadata;
66

7+
use InvalidArgumentException;
78
use Patchlevel\Hydrator\Normalizer\Normalizer;
89
use ReflectionProperty;
910

11+
use function str_starts_with;
12+
1013
/**
1114
* @psalm-type serialized = array{
1215
* className: class-string,
@@ -19,13 +22,18 @@
1922
*/
2023
final class PropertyMetadata
2124
{
25+
private const ENCRYPTED_PREFIX = '!';
26+
2227
public function __construct(
2328
private readonly ReflectionProperty $reflection,
2429
private readonly string $fieldName,
2530
private readonly Normalizer|null $normalizer = null,
2631
private readonly bool $isPersonalData = false,
2732
private readonly mixed $personalDataFallback = null,
2833
) {
34+
if (str_starts_with($fieldName, self::ENCRYPTED_PREFIX)) {
35+
throw new InvalidArgumentException('fieldName must not start with !');
36+
}
2937
}
3038

3139
public function reflection(): ReflectionProperty
@@ -43,6 +51,11 @@ public function fieldName(): string
4351
return $this->fieldName;
4452
}
4553

54+
public function encryptedFieldName(): string
55+
{
56+
return self::ENCRYPTED_PREFIX . $this->fieldName;
57+
}
58+
4659
public function normalizer(): Normalizer|null
4760
{
4861
return $this->normalizer;

tests/Unit/Cryptography/PersonalDataPayloadCryptographerTest.php

Lines changed: 68 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -77,7 +77,7 @@ public function testEncryptWithMissingKey(): void
7777

7878
$result = $cryptographer->encrypt($this->metadata(PersonalDataProfileCreated::class), ['id' => 'foo', 'email' => 'info@patchlevel.de']);
7979

80-
self::assertEquals(['id' => 'foo', 'email' => 'encrypted'], $result);
80+
self::assertEquals(['id' => 'foo', '!email' => 'encrypted'], $result);
8181
}
8282

8383
public function testEncryptWithExistingKey(): void
@@ -109,7 +109,7 @@ public function testEncryptWithExistingKey(): void
109109

110110
$result = $cryptographer->encrypt($this->metadata(PersonalDataProfileCreated::class), ['id' => 'foo', 'email' => 'info@patchlevel.de']);
111111

112-
self::assertEquals(['id' => 'foo', 'email' => 'encrypted'], $result);
112+
self::assertEquals(['id' => 'foo', '!email' => 'encrypted'], $result);
113113
}
114114

115115
public function testSkipDecrypt(): void
@@ -148,9 +148,10 @@ public function testDecryptWithMissingKey(): void
148148
$cipherKeyStore->reveal(),
149149
$cipherKeyFactory->reveal(),
150150
$cipher->reveal(),
151+
false,
151152
);
152153

153-
$result = $cryptographer->decrypt($this->metadata(PersonalDataProfileCreated::class), ['id' => 'foo', 'email' => 'encrypted']);
154+
$result = $cryptographer->decrypt($this->metadata(PersonalDataProfileCreated::class), ['id' => 'foo', '!email' => 'encrypted']);
154155

155156
self::assertEquals(['id' => 'foo', 'email' => new Email('unknown')], $result);
156157
}
@@ -180,9 +181,10 @@ public function testDecryptWithInvalidKey(): void
180181
$cipherKeyStore->reveal(),
181182
$cipherKeyFactory->reveal(),
182183
$cipher->reveal(),
184+
false,
183185
);
184186

185-
$result = $cryptographer->decrypt($this->metadata(PersonalDataProfileCreated::class), ['id' => 'foo', 'email' => 'encrypted']);
187+
$result = $cryptographer->decrypt($this->metadata(PersonalDataProfileCreated::class), ['id' => 'foo', '!email' => 'encrypted']);
186188

187189
self::assertEquals(['id' => 'foo', 'email' => new Email('unknown')], $result);
188190
}
@@ -202,6 +204,68 @@ public function testDecryptWithExistingKey(): void
202204
$cipherKeyFactory = $this->prophesize(CipherKeyFactory::class);
203205
$cipherKeyFactory->__invoke()->shouldNotBeCalled();
204206

207+
$cipher = $this->prophesize(Cipher::class);
208+
$cipher
209+
->decrypt($cipherKey, 'encrypted')
210+
->willReturn('info@patchlevel.de')
211+
->shouldBeCalledOnce();
212+
213+
$cryptographer = new PersonalDataPayloadCryptographer(
214+
$cipherKeyStore->reveal(),
215+
$cipherKeyFactory->reveal(),
216+
$cipher->reveal(),
217+
false,
218+
);
219+
220+
$result = $cryptographer->decrypt($this->metadata(PersonalDataProfileCreated::class), ['id' => 'foo', '!email' => 'encrypted']);
221+
222+
self::assertEquals(['id' => 'foo', 'email' => 'info@patchlevel.de'], $result);
223+
}
224+
225+
public function testDecryptWithoutPrefixField(): void
226+
{
227+
$cipherKey = new CipherKey(
228+
'foo',
229+
'bar',
230+
'baz',
231+
);
232+
233+
$cipherKeyStore = $this->prophesize(CipherKeyStore::class);
234+
$cipherKeyStore->get('foo')->willReturn($cipherKey);
235+
$cipherKeyStore->store('foo', Argument::type(CipherKey::class))->shouldNotBeCalled();
236+
237+
$cipherKeyFactory = $this->prophesize(CipherKeyFactory::class);
238+
$cipherKeyFactory->__invoke()->shouldNotBeCalled();
239+
240+
$cipher = $this->prophesize(Cipher::class);
241+
242+
$cryptographer = new PersonalDataPayloadCryptographer(
243+
$cipherKeyStore->reveal(),
244+
$cipherKeyFactory->reveal(),
245+
$cipher->reveal(),
246+
false,
247+
);
248+
249+
$result = $cryptographer->decrypt($this->metadata(PersonalDataProfileCreated::class), ['id' => 'foo', 'email' => 'info@patchlevel.de']);
250+
251+
self::assertEquals(['id' => 'foo', 'email' => 'info@patchlevel.de'], $result);
252+
}
253+
254+
public function testDecryptWithFallbackWithoutPrefix(): void
255+
{
256+
$cipherKey = new CipherKey(
257+
'foo',
258+
'bar',
259+
'baz',
260+
);
261+
262+
$cipherKeyStore = $this->prophesize(CipherKeyStore::class);
263+
$cipherKeyStore->get('foo')->willReturn($cipherKey);
264+
$cipherKeyStore->store('foo', Argument::type(CipherKey::class))->shouldNotBeCalled();
265+
266+
$cipherKeyFactory = $this->prophesize(CipherKeyFactory::class);
267+
$cipherKeyFactory->__invoke()->shouldNotBeCalled();
268+
205269
$cipher = $this->prophesize(Cipher::class);
206270
$cipher
207271
->decrypt($cipherKey, 'encrypted')

0 commit comments

Comments
 (0)