diff --git a/src/Cryptography/CryptoNormalizer.php b/src/Cryptography/CryptoNormalizer.php new file mode 100644 index 0000000..3bf4a1c --- /dev/null +++ b/src/Cryptography/CryptoNormalizer.php @@ -0,0 +1,75 @@ + $context + * + * @throws InvalidArgument + */ + public function normalize(mixed $value, array $context = []): mixed + { + if ($this->normalizer !== null) { + $value = $this->normalizer->normalize($value, $context); + } + + if ($value === null) { + return null; + } + + $subjectId = $context[SubjectIds::class]->get($this->subjectIdName); + + return ['__enc' => $this->cryptographer->encrypt($subjectId, $value)]; + } + + /** + * @param array $context + * + * @throws InvalidArgument + */ + public function denormalize(mixed $value, array $context = []): mixed + { + if (!is_array($value) || !array_key_exists('__enc', $value)) { + if ($this->normalizer === null) { + return $value; + } + + return $this->normalizer->denormalize($value, $context); + } + + $subjectId = $context[SubjectIds::class]->get($this->subjectIdName); + + try { + $data = $this->cryptographer->decrypt($subjectId, $value['__enc']); + } catch (DecryptionFailed|CipherKeyNotExists) { + if ($this->fallback instanceof Closure) { + return ($this->fallback)($subjectId, $value['__enc']); + } + + return $this->fallback; + } + + if ($this->normalizer === null) { + return $data; + } + + return $this->normalizer->denormalize($data, $context); + } +} \ No newline at end of file diff --git a/src/Cryptography/Cryptographer.php b/src/Cryptography/Cryptographer.php new file mode 100644 index 0000000..ee55c7c --- /dev/null +++ b/src/Cryptography/Cryptographer.php @@ -0,0 +1,62 @@ +cipherKeyStore->get($subjectId); + } catch (CipherKeyNotExists) { + $cipherKey = ($this->cipherKeyFactory)(); + $this->cipherKeyStore->store($subjectId, $cipherKey); + } + + return $this->cipher->encrypt($cipherKey, $value); + } + + /** + * @throws CipherKeyNotExists + * @throws DecryptionFailed + */ + public function decrypt(string $subjectId, string $value): mixed + { + $cipherKey = $this->cipherKeyStore->get($subjectId); + + return $this->cipher->decrypt($cipherKey, $value); + } + + + /** @param non-empty-string $method */ + public static function createWithOpenssl( + CipherKeyStore $cryptoStore, + string $method = OpensslCipherKeyFactory::DEFAULT_METHOD, + ): static { + return new self( + new OpensslCipher(), + $cryptoStore, + new OpensslCipherKeyFactory($method), + ); + } +} \ No newline at end of file diff --git a/src/Cryptography/CryptographyMetadataFactory.php b/src/Cryptography/CryptographyMetadataFactory.php index d74c3ee..661343f 100644 --- a/src/Cryptography/CryptographyMetadataFactory.php +++ b/src/Cryptography/CryptographyMetadataFactory.php @@ -8,13 +8,13 @@ use Patchlevel\Hydrator\Attribute\SensitiveData; use Patchlevel\Hydrator\Metadata\ClassMetadata; use Patchlevel\Hydrator\Metadata\MetadataFactory; -use ReflectionProperty; use function array_key_exists; final class CryptographyMetadataFactory implements MetadataFactory { public function __construct( + private readonly Cryptographer $cryptographer, private readonly MetadataFactory $metadataFactory, ) { } @@ -46,9 +46,9 @@ public function metadata(string $class): ClassMetadata $isSubjectId = true; } - $sensitiveDataInfo = $this->sensitiveDataInfo($property->reflection); + $attributeReflectionList = $property->reflection->getAttributes(SensitiveData::class); - if (!$sensitiveDataInfo) { + if ($attributeReflectionList === []) { continue; } @@ -56,7 +56,14 @@ public function metadata(string $class): ClassMetadata throw new SubjectIdAndSensitiveDataConflict($metadata->className, $property->propertyName); } - $property->extras[SensitiveDataInfo::class] = $sensitiveDataInfo; + $attribute = $attributeReflectionList[0]->newInstance(); + + $property->normalizer = new CryptoNormalizer( + $this->cryptographer, + $attribute->subjectIdName, + $attribute->fallbackCallable !== null ? ($attribute->fallbackCallable)(...) : $attribute->fallback, + $property->normalizer, + ); } if ($subjectIdMapping !== []) { @@ -65,20 +72,4 @@ public function metadata(string $class): ClassMetadata return $metadata; } - - private function sensitiveDataInfo(ReflectionProperty $reflectionProperty): SensitiveDataInfo|null - { - $attributeReflectionList = $reflectionProperty->getAttributes(SensitiveData::class); - - if ($attributeReflectionList === []) { - return null; - } - - $attribute = $attributeReflectionList[0]->newInstance(); - - return new SensitiveDataInfo( - $attribute->subjectIdName, - $attribute->fallbackCallable !== null ? ($attribute->fallbackCallable)(...) : $attribute->fallback, - ); - } } diff --git a/src/Cryptography/CryptographyMiddleware.php b/src/Cryptography/CryptographyMiddleware.php index a9c7330..d384ff8 100644 --- a/src/Cryptography/CryptographyMiddleware.php +++ b/src/Cryptography/CryptographyMiddleware.php @@ -4,14 +4,6 @@ namespace Patchlevel\Hydrator\Cryptography; -use Closure; -use Patchlevel\Hydrator\Cryptography\Cipher\Cipher; -use Patchlevel\Hydrator\Cryptography\Cipher\CipherKeyFactory; -use Patchlevel\Hydrator\Cryptography\Cipher\DecryptionFailed; -use Patchlevel\Hydrator\Cryptography\Cipher\OpensslCipher; -use Patchlevel\Hydrator\Cryptography\Cipher\OpensslCipherKeyFactory; -use Patchlevel\Hydrator\Cryptography\Store\CipherKeyNotExists; -use Patchlevel\Hydrator\Cryptography\Store\CipherKeyStore; use Patchlevel\Hydrator\Metadata\ClassMetadata; use Patchlevel\Hydrator\Middleware\Middleware; use Patchlevel\Hydrator\Middleware\Stack; @@ -25,16 +17,6 @@ final class CryptographyMiddleware implements Middleware { - private const DEFAULT_ENCRYPTED_FIELD_NAME_PREFIX = '!'; - - public function __construct( - private readonly Cipher $cipher, - private readonly CipherKeyStore $cipherKeyStore, - private readonly CipherKeyFactory $cipherKeyFactory, - private readonly string|null $encryptedFieldNamePrefix = self::DEFAULT_ENCRYPTED_FIELD_NAME_PREFIX, - ) { - } - /** * @param ClassMetadata $metadata * @param array $data @@ -46,68 +28,7 @@ public function __construct( */ public function hydrate(ClassMetadata $metadata, array $data, array $context, Stack $stack): object { - $subjectIds = $context[SubjectIds::class] ?? new SubjectIds(); - - assert($subjectIds instanceof SubjectIds); - - $mapping = $metadata->extras[SubjectIdFieldMapping::class] ?? null; - - if ($mapping instanceof SubjectIdFieldMapping) { - $subjectIds = $this->resolveSubjectIds($metadata, $mapping, $data) - ->merge($subjectIds); - } - - $context[SubjectIds::class] = $subjectIds; - - foreach ($metadata->properties as $propertyMetadata) { - $sensitiveDataInfo = $propertyMetadata->extras[SensitiveDataInfo::class] ?? null; - - if (!$sensitiveDataInfo instanceof SensitiveDataInfo) { - continue; - } - - $subjectId = $subjectIds->get($sensitiveDataInfo->subjectIdName); - - try { - $cipherKey = $this->cipherKeyStore->get($subjectId); - } catch (CipherKeyNotExists) { - $cipherKey = null; - } - - if ( - $this->encryptedFieldNamePrefix && array_key_exists( - $this->encryptedFieldNamePrefix . $propertyMetadata->fieldName, - $data, - ) - ) { - $rawData = $data[$this->encryptedFieldNamePrefix . $propertyMetadata->fieldName]; - unset($data[$this->encryptedFieldNamePrefix . $propertyMetadata->fieldName]); - } elseif (!$this->encryptedFieldNamePrefix) { - $rawData = $data[$propertyMetadata->fieldName]; - } else { - continue; - } - - if (!is_string($rawData)) { - $data[$propertyMetadata->fieldName] = $rawData; - - continue; - } - - if (!$cipherKey) { - $data[$propertyMetadata->fieldName] = $this->fallback($sensitiveDataInfo, $subjectId, $rawData); - continue; - } - - try { - $data[$propertyMetadata->fieldName] = $this->cipher->decrypt( - $cipherKey, - $rawData, - ); - } catch (DecryptionFailed) { - $data[$propertyMetadata->fieldName] = $this->fallback($sensitiveDataInfo, $subjectId, $rawData); - } - } + $context[SubjectIds::class] = $this->resolveSubjectIds($metadata, $data, $context); return $stack->next()->hydrate( $metadata, @@ -128,62 +49,30 @@ public function hydrate(ClassMetadata $metadata, array $data, array $context, St */ public function extract(ClassMetadata $metadata, object $object, array $context, Stack $stack): array { + $context[SubjectIds::class] = $this->resolveSubjectIds($metadata, $object, $context); + + return $stack->next()->extract($metadata, $object, $context, $stack); + } + + /** + * @param array|object $data + * @param array $context + */ + private function resolveSubjectIds( + ClassMetadata $metadata, + array|object $data, + array $context, + ): SubjectIds { $subjectIds = $context[SubjectIds::class] ?? new SubjectIds(); assert($subjectIds instanceof SubjectIds); $mapping = $metadata->extras[SubjectIdFieldMapping::class] ?? null; - if ($mapping instanceof SubjectIdFieldMapping) { - $subjectIds = $this->resolveSubjectIds($metadata, $mapping, $object) - ->merge($subjectIds); - } - - $context[SubjectIds::class] = $subjectIds; - - $data = $stack->next()->extract($metadata, $object, $context, $stack); - - foreach ($metadata->properties as $propertyMetadata) { - $sensitiveDataInfo = $propertyMetadata->extras[SensitiveDataInfo::class] ?? null; - - if (!$sensitiveDataInfo instanceof SensitiveDataInfo) { - continue; - } - - $subjectId = $subjectIds->get($sensitiveDataInfo->subjectIdName); - - try { - $cipherKey = $this->cipherKeyStore->get($subjectId); - } catch (CipherKeyNotExists) { - $cipherKey = ($this->cipherKeyFactory)(); - $this->cipherKeyStore->store($subjectId, $cipherKey); - } - - $targetFieldName = $this->encryptedFieldNamePrefix - ? $this->encryptedFieldNamePrefix . $propertyMetadata->fieldName - : $propertyMetadata->fieldName; - - $data[$targetFieldName] = $this->cipher->encrypt( - $cipherKey, - $data[$propertyMetadata->fieldName], - ); - - if (!$this->encryptedFieldNamePrefix) { - continue; - } - - unset($data[$propertyMetadata->fieldName]); + if (!$mapping instanceof SubjectIdFieldMapping) { + return $subjectIds; } - return $data; - } - - /** @param array|object $data */ - private function resolveSubjectIds( - ClassMetadata $metadata, - SubjectIdFieldMapping $mapping, - array|object $data, - ): SubjectIds { $result = []; foreach ($mapping->nameToField as $name => $fieldName) { @@ -218,29 +107,6 @@ private function resolveSubjectIds( $result[$name] = $subjectId; } - return new SubjectIds($result); - } - - private function fallback(SensitiveDataInfo $sensitiveDataInfo, string $subjectId, mixed $rawData): mixed - { - if ($sensitiveDataInfo->fallback instanceof Closure) { - return ($sensitiveDataInfo->fallback)($subjectId, $rawData); - } - - return $sensitiveDataInfo->fallback; - } - - /** @param non-empty-string $method */ - public static function createWithOpenssl( - CipherKeyStore $cryptoStore, - string $method = OpensslCipherKeyFactory::DEFAULT_METHOD, - string|null $encryptedFieldNamePrefix = self::DEFAULT_ENCRYPTED_FIELD_NAME_PREFIX, - ): static { - return new self( - new OpensslCipher(), - $cryptoStore, - new OpensslCipherKeyFactory($method), - $encryptedFieldNamePrefix, - ); + return $subjectIds->merge(new SubjectIds($result)); } } diff --git a/src/Metadata/PropertyMetadata.php b/src/Metadata/PropertyMetadata.php index 2bf6be1..3762323 100644 --- a/src/Metadata/PropertyMetadata.php +++ b/src/Metadata/PropertyMetadata.php @@ -24,7 +24,7 @@ final class PropertyMetadata public function __construct( public readonly ReflectionProperty $reflection, public readonly string $fieldName, - public readonly Normalizer|null $normalizer = null, + public Normalizer|null $normalizer = null, public array $extras = [], ) { $this->propertyName = $reflection->getName(); diff --git a/tests/Benchmark/HydratorWithCryptographyBench.php b/tests/Benchmark/HydratorWithCryptographyBench.php index 9ca332a..e686ae9 100644 --- a/tests/Benchmark/HydratorWithCryptographyBench.php +++ b/tests/Benchmark/HydratorWithCryptographyBench.php @@ -4,6 +4,7 @@ namespace Patchlevel\Hydrator\Tests\Benchmark; +use Patchlevel\Hydrator\Cryptography\Cryptographer; use Patchlevel\Hydrator\Cryptography\CryptographyMetadataFactory; use Patchlevel\Hydrator\Cryptography\CryptographyMiddleware; use Patchlevel\Hydrator\Cryptography\Store\InMemoryCipherKeyStore; @@ -28,9 +29,12 @@ public function __construct() $this->store = new InMemoryCipherKeyStore(); $this->hydrator = new MetadataHydrator( - new CryptographyMetadataFactory(new AttributeMetadataFactory()), + new CryptographyMetadataFactory( + Cryptographer::createWithOpenssl($this->store), + new AttributeMetadataFactory(), + ), [ - CryptographyMiddleware::createWithOpenssl($this->store), + new CryptographyMiddleware(), new TransformMiddleware(), ], );