Skip to content

Commit ddf20ea

Browse files
committed
add field prefix for encrypted fields
1 parent 88edff2 commit ddf20ea

File tree

3 files changed

+93
-6
lines changed

3 files changed

+93
-6
lines changed

src/Cryptography/PersonalDataPayloadCryptographer.php

Lines changed: 18 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -19,10 +19,13 @@
1919

2020
final class PersonalDataPayloadCryptographer implements PayloadCryptographer
2121
{
22+
public const string ENCRYPTED_PREFIX = '!';
23+
2224
public function __construct(
2325
private readonly CipherKeyStore $cipherKeyStore,
2426
private readonly CipherKeyFactory $cipherKeyFactory,
2527
private readonly Cipher $cipher,
28+
private readonly bool $fallbackWithoutPrefix = true,
2629
) {
2730
}
2831

@@ -51,10 +54,12 @@ public function encrypt(ClassMetadata $metadata, array $data): array
5154
continue;
5255
}
5356

54-
$data[$propertyMetadata->fieldName()] = $this->cipher->encrypt(
57+
$data[self::ENCRYPTED_PREFIX . $propertyMetadata->fieldName()] = $this->cipher->encrypt(
5558
$cipherKey,
5659
$data[$propertyMetadata->fieldName()],
5760
);
61+
62+
unset($data[$propertyMetadata->fieldName()]);
5863
}
5964

6065
return $data;
@@ -84,6 +89,17 @@ public function decrypt(ClassMetadata $metadata, array $data): array
8489
continue;
8590
}
8691

92+
$fieldNameWithPrefix = self::ENCRYPTED_PREFIX . $propertyMetadata->fieldName();
93+
94+
if (array_key_exists($fieldNameWithPrefix, $data)) {
95+
$rawData = $data[$fieldNameWithPrefix];
96+
unset($data[$fieldNameWithPrefix]);
97+
} elseif ($this->fallbackWithoutPrefix) {
98+
$rawData = $data[$propertyMetadata->fieldName()];
99+
} else {
100+
continue;
101+
}
102+
87103
if (!$cipherKey) {
88104
$data[$propertyMetadata->fieldName()] = $propertyMetadata->personalDataFallback();
89105
continue;
@@ -92,7 +108,7 @@ public function decrypt(ClassMetadata $metadata, array $data): array
92108
try {
93109
$data[$propertyMetadata->fieldName()] = $this->cipher->decrypt(
94110
$cipherKey,
95-
$data[$propertyMetadata->fieldName()],
111+
$rawData,
96112
);
97113
} catch (DecryptionFailed) {
98114
$data[$propertyMetadata->fieldName()] = $propertyMetadata->personalDataFallback();

src/Metadata/PropertyMetadata.php

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

55
namespace Patchlevel\Hydrator\Metadata;
66

7+
use InvalidArgumentException;
8+
use Patchlevel\Hydrator\Cryptography\PersonalDataPayloadCryptographer;
79
use Patchlevel\Hydrator\Normalizer\Normalizer;
810
use ReflectionProperty;
911

12+
use function str_starts_with;
13+
1014
/**
1115
* @psalm-type serialized = array{
1216
* className: class-string,
@@ -26,6 +30,9 @@ public function __construct(
2630
private readonly bool $isPersonalData = false,
2731
private readonly mixed $personalDataFallback = null,
2832
) {
33+
if (str_starts_with($fieldName, PersonalDataPayloadCryptographer::ENCRYPTED_PREFIX)) {
34+
throw new InvalidArgumentException('fieldName must not start with !');
35+
}
2936
}
3037

3138
public function reflection(): ReflectionProperty

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)