diff --git a/phpstan-baseline.neon b/phpstan-baseline.neon index 34a14408..e97a4630 100644 --- a/phpstan-baseline.neon +++ b/phpstan-baseline.neon @@ -12,12 +12,6 @@ parameters: count: 1 path: src/Cryptography/Cipher/OpensslCipherKeyFactory.php - - - message: '#^Parameter \#2 \$data of method Patchlevel\\Hydrator\\Cryptography\\Cipher\\Cipher\:\:decrypt\(\) expects string, mixed given\.$#' - identifier: argument.type - count: 1 - path: src/Cryptography/PersonalDataPayloadCryptographer.php - - message: '#^Offset ''k'' on array\{v\: 1, a\: non\-empty\-string, k\: non\-empty\-string, n\?\: non\-empty\-string, d\: non\-empty\-string, t\?\: non\-empty\-string\} on left side of \?\? always exists and is not nullable\.$#' identifier: nullCoalesce.offset diff --git a/src/Cryptography/PersonalDataPayloadCryptographer.php b/src/Cryptography/PersonalDataPayloadCryptographer.php index b8de152c..8db7d9b2 100644 --- a/src/Cryptography/PersonalDataPayloadCryptographer.php +++ b/src/Cryptography/PersonalDataPayloadCryptographer.php @@ -27,6 +27,7 @@ public function __construct( private readonly Cipher $cipher, private readonly bool $useEncryptedFieldName = false, private readonly bool $fallbackToFieldName = false, + private readonly bool $encryptNull = true, ) { } @@ -55,13 +56,19 @@ public function encrypt(ClassMetadata $metadata, array $data): array continue; } + $value = $data[$propertyMetadata->fieldName()] ?? null; + + if (!$this->encryptNull && $value === null) { + continue; + } + $targetFieldName = $this->useEncryptedFieldName ? $propertyMetadata->encryptedFieldName() : $propertyMetadata->fieldName(); $data[$targetFieldName] = $this->cipher->encrypt( $cipherKey, - $data[$propertyMetadata->fieldName()], + $value, ); if (!$this->useEncryptedFieldName) { @@ -107,6 +114,10 @@ public function decrypt(ClassMetadata $metadata, array $data): array continue; } + if (!is_string($rawData)) { + continue; + } + if (!$cipherKey) { $data[$propertyMetadata->fieldName()] = $this->fallback($propertyMetadata, $subjectId, $rawData); continue; diff --git a/tests/Unit/Cryptography/PersonalDataPayloadCryptographerTest.php b/tests/Unit/Cryptography/PersonalDataPayloadCryptographerTest.php index f1037e6e..bdbb108d 100644 --- a/tests/Unit/Cryptography/PersonalDataPayloadCryptographerTest.php +++ b/tests/Unit/Cryptography/PersonalDataPayloadCryptographerTest.php @@ -144,6 +144,74 @@ public function testEncryptWithExistingKeyEncryptedFieldName(): void self::assertEquals(['id' => 'foo', '!email' => 'encrypted'], $result); } + public function testEncryptSkipNullValueIfEncryptNullDisabled(): void + { + $cipherKey = new CipherKey( + 'foo', + 'bar', + 'baz', + ); + + $cipherKeyStore = $this->createMock(CipherKeyStore::class); + $cipherKeyStore->method('get')->with('foo')->willReturn($cipherKey); + $cipherKeyStore->expects($this->never())->method('store'); + + $cipherKeyFactory = $this->createMock(CipherKeyFactory::class); + $cipherKeyFactory->expects($this->never())->method('__invoke'); + + $cipher = $this->createMock(Cipher::class); + $cipher->expects($this->never())->method('encrypt'); + + $cryptographer = new PersonalDataPayloadCryptographer( + $cipherKeyStore, + $cipherKeyFactory, + $cipher, + false, + false, + false, + ); + + $result = $cryptographer->encrypt( + $this->metadata(PersonalDataProfileCreated::class), + ['id' => 'foo', 'email' => null], + ); + + self::assertSame(['id' => 'foo', 'email' => null], $result); + } + + public function testEncryptNullValueIfEncryptNullEnabled(): void + { + $cipherKey = new CipherKey( + 'foo', + 'bar', + 'baz', + ); + + $cipherKeyStore = $this->createMock(CipherKeyStore::class); + $cipherKeyStore->method('get')->with('foo')->willReturn($cipherKey); + $cipherKeyStore->expects($this->never())->method('store'); + + $cipherKeyFactory = $this->createMock(CipherKeyFactory::class); + $cipherKeyFactory->expects($this->never())->method('__invoke'); + + $cipher = $this->createMock(Cipher::class); + $cipher->expects($this->once())->method('encrypt')->with($cipherKey, null) + ->willReturn('encrypted-null'); + + $cryptographer = new PersonalDataPayloadCryptographer( + $cipherKeyStore, + $cipherKeyFactory, + $cipher, + ); + + $result = $cryptographer->encrypt( + $this->metadata(PersonalDataProfileCreated::class), + ['id' => 'foo', 'email' => null], + ); + + self::assertSame(['id' => 'foo', 'email' => 'encrypted-null'], $result); + } + public function testSkipDecrypt(): void { $cipherKeyStore = $this->createMock(CipherKeyStore::class); @@ -373,6 +441,30 @@ public function testDecryptWithValidKeyAndEncryptedFieldNameAndFallbackFieldName self::assertEquals(['id' => 'foo', 'email' => 'info@patchlevel.de'], $result); } + public function testDecryptSkipNonStringValue(): void + { + $cipherKeyStore = $this->createMock(CipherKeyStore::class); + $cipherKeyStore->method('get')->with('foo')->willThrowException(new CipherKeyNotExists('foo')); + + $cipherKeyFactory = $this->createMock(CipherKeyFactory::class); + $cipher = $this->createMock(Cipher::class); + $cipher->expects($this->never())->method('decrypt'); + + $cryptographer = new PersonalDataPayloadCryptographer( + $cipherKeyStore, + $cipherKeyFactory, + $cipher, + ); + + $email = new Email('info@patchlevel.de'); + $result = $cryptographer->decrypt( + $this->metadata(PersonalDataProfileCreated::class), + ['id' => 'foo', 'email' => $email], + ); + + self::assertSame(['id' => 'foo', 'email' => $email], $result); + } + public function testUnsupportedSubjectId(): void { $this->expectException(UnsupportedSubjectId::class);