Skip to content
Merged
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
6 changes: 4 additions & 2 deletions baseline.xml
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
<?xml version="1.0" encoding="UTF-8"?>
<files psalm-version="5.26.1@d747f6500b38ac4f7dfc5edbcae6e4b637d7add0">
<files psalm-version="6.9.1@81c8a77c0793d450fee40265cfe68891df11d505">
<file src="src/Cryptography/Cipher/OpensslCipherKeyFactory.php">
<ArgumentTypeCoercion>
<code><![CDATA[openssl_random_pseudo_bytes($this->ivLength)]]></code>
Expand All @@ -14,12 +14,14 @@
</file>
<file src="src/Cryptography/PersonalDataPayloadCryptographer.php">
<MixedArgument>
<code><![CDATA[$data[$propertyMetadata->fieldName()]]]></code>
<code><![CDATA[$rawData]]></code>
</MixedArgument>
<MixedAssignment>
<code><![CDATA[$data[$propertyMetadata->fieldName()]]]></code>
<code><![CDATA[$data[$propertyMetadata->fieldName()]]]></code>
<code><![CDATA[$data[$propertyMetadata->fieldName()]]]></code>
<code><![CDATA[$rawData]]></code>
<code><![CDATA[$rawData]]></code>
</MixedAssignment>
</file>
<file src="src/Metadata/AttributeMetadataFactory.php">
Expand Down
16 changes: 14 additions & 2 deletions src/Cryptography/PersonalDataPayloadCryptographer.php
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,7 @@ public function __construct(
private readonly CipherKeyStore $cipherKeyStore,
private readonly CipherKeyFactory $cipherKeyFactory,
private readonly Cipher $cipher,
private readonly bool $fallbackWithoutPrefix = true,
) {
}

Expand Down Expand Up @@ -51,10 +52,12 @@ public function encrypt(ClassMetadata $metadata, array $data): array
continue;
}

$data[$propertyMetadata->fieldName()] = $this->cipher->encrypt(
$data[$propertyMetadata->encryptedFieldName()] = $this->cipher->encrypt(
$cipherKey,
$data[$propertyMetadata->fieldName()],
);

unset($data[$propertyMetadata->fieldName()]);
}

return $data;
Expand Down Expand Up @@ -84,6 +87,15 @@ public function decrypt(ClassMetadata $metadata, array $data): array
continue;
}

if (array_key_exists($propertyMetadata->encryptedFieldName(), $data)) {
$rawData = $data[$propertyMetadata->encryptedFieldName()];
unset($data[$propertyMetadata->encryptedFieldName()]);
} elseif ($this->fallbackWithoutPrefix) {
$rawData = $data[$propertyMetadata->fieldName()];
} else {
continue;
}

if (!$cipherKey) {
$data[$propertyMetadata->fieldName()] = $propertyMetadata->personalDataFallback();
continue;
Expand All @@ -92,7 +104,7 @@ public function decrypt(ClassMetadata $metadata, array $data): array
try {
$data[$propertyMetadata->fieldName()] = $this->cipher->decrypt(
$cipherKey,
$data[$propertyMetadata->fieldName()],
$rawData,
);
} catch (DecryptionFailed) {
$data[$propertyMetadata->fieldName()] = $propertyMetadata->personalDataFallback();
Expand Down
13 changes: 13 additions & 0 deletions src/Metadata/PropertyMetadata.php
Original file line number Diff line number Diff line change
Expand Up @@ -4,9 +4,12 @@

namespace Patchlevel\Hydrator\Metadata;

use InvalidArgumentException;
use Patchlevel\Hydrator\Normalizer\Normalizer;
use ReflectionProperty;

use function str_starts_with;

/**
* @psalm-type serialized = array{
* className: class-string,
Expand All @@ -19,13 +22,18 @@
*/
final class PropertyMetadata
{
private const ENCRYPTED_PREFIX = '!';

public function __construct(
private readonly ReflectionProperty $reflection,
private readonly string $fieldName,
private readonly Normalizer|null $normalizer = null,
private readonly bool $isPersonalData = false,
private readonly mixed $personalDataFallback = null,
) {
if (str_starts_with($fieldName, self::ENCRYPTED_PREFIX)) {
throw new InvalidArgumentException('fieldName must not start with !');
}
}

public function reflection(): ReflectionProperty
Expand All @@ -43,6 +51,11 @@ public function fieldName(): string
return $this->fieldName;
}

public function encryptedFieldName(): string
{
return self::ENCRYPTED_PREFIX . $this->fieldName;
}

public function normalizer(): Normalizer|null
{
return $this->normalizer;
Expand Down
72 changes: 68 additions & 4 deletions tests/Unit/Cryptography/PersonalDataPayloadCryptographerTest.php
Original file line number Diff line number Diff line change
Expand Up @@ -77,7 +77,7 @@ public function testEncryptWithMissingKey(): void

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

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

public function testEncryptWithExistingKey(): void
Expand Down Expand Up @@ -109,7 +109,7 @@ public function testEncryptWithExistingKey(): void

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

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

public function testSkipDecrypt(): void
Expand Down Expand Up @@ -148,9 +148,10 @@ public function testDecryptWithMissingKey(): void
$cipherKeyStore->reveal(),
$cipherKeyFactory->reveal(),
$cipher->reveal(),
false,
);

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

self::assertEquals(['id' => 'foo', 'email' => new Email('unknown')], $result);
}
Expand Down Expand Up @@ -180,9 +181,10 @@ public function testDecryptWithInvalidKey(): void
$cipherKeyStore->reveal(),
$cipherKeyFactory->reveal(),
$cipher->reveal(),
false,
);

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

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

$cipher = $this->prophesize(Cipher::class);
$cipher
->decrypt($cipherKey, 'encrypted')
->willReturn('info@patchlevel.de')
->shouldBeCalledOnce();

$cryptographer = new PersonalDataPayloadCryptographer(
$cipherKeyStore->reveal(),
$cipherKeyFactory->reveal(),
$cipher->reveal(),
false,
);

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

self::assertEquals(['id' => 'foo', 'email' => 'info@patchlevel.de'], $result);
}

public function testDecryptWithoutPrefixField(): void
{
$cipherKey = new CipherKey(
'foo',
'bar',
'baz',
);

$cipherKeyStore = $this->prophesize(CipherKeyStore::class);
$cipherKeyStore->get('foo')->willReturn($cipherKey);
$cipherKeyStore->store('foo', Argument::type(CipherKey::class))->shouldNotBeCalled();

$cipherKeyFactory = $this->prophesize(CipherKeyFactory::class);
$cipherKeyFactory->__invoke()->shouldNotBeCalled();

$cipher = $this->prophesize(Cipher::class);

$cryptographer = new PersonalDataPayloadCryptographer(
$cipherKeyStore->reveal(),
$cipherKeyFactory->reveal(),
$cipher->reveal(),
false,
);

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

self::assertEquals(['id' => 'foo', 'email' => 'info@patchlevel.de'], $result);
}

public function testDecryptWithFallbackWithoutPrefix(): void
{
$cipherKey = new CipherKey(
'foo',
'bar',
'baz',
);

$cipherKeyStore = $this->prophesize(CipherKeyStore::class);
$cipherKeyStore->get('foo')->willReturn($cipherKey);
$cipherKeyStore->store('foo', Argument::type(CipherKey::class))->shouldNotBeCalled();

$cipherKeyFactory = $this->prophesize(CipherKeyFactory::class);
$cipherKeyFactory->__invoke()->shouldNotBeCalled();

$cipher = $this->prophesize(Cipher::class);
$cipher
->decrypt($cipherKey, 'encrypted')
Expand Down
Loading