Skip to content
Draft
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
4 changes: 4 additions & 0 deletions phpstan.neon.dist
Original file line number Diff line number Diff line change
Expand Up @@ -15,3 +15,7 @@ services:
class: Patchlevel\Hydrator\Tests\Architecture\FinalClassesTest
tags:
- phpat.test
-
class: Patchlevel\Hydrator\Tests\Architecture\ExceptionImplementsHydratorExceptionTest
tags:
- phpat.test
2 changes: 1 addition & 1 deletion src/Attribute/SensitiveData.php
Original file line number Diff line number Diff line change
Expand Up @@ -10,7 +10,7 @@
#[Attribute(Attribute::TARGET_PROPERTY)]
final class SensitiveData
{
/** @var (callable(string, mixed):mixed)|null */
/** @var (callable(string):mixed)|null */
public readonly mixed $fallbackCallable;

public function __construct(
Expand Down
91 changes: 91 additions & 0 deletions src/Cryptography/BaseCryptographer.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,91 @@
<?php

namespace Patchlevel\Hydrator\Cryptography;

use Patchlevel\Hydrator\Cryptography\Cipher\Cipher;
use Patchlevel\Hydrator\Cryptography\Cipher\CipherKey;
use Patchlevel\Hydrator\Cryptography\Cipher\CipherKeyFactory;
use Patchlevel\Hydrator\Cryptography\Cipher\DecryptionFailed;
use Patchlevel\Hydrator\Cryptography\Cipher\EncryptionFailed;
use Patchlevel\Hydrator\Cryptography\Cipher\OpensslCipher;
use Patchlevel\Hydrator\Cryptography\Cipher\OpensslCipherKeyFactory;
use Patchlevel\Hydrator\Cryptography\Store\CipherKeyNotExists;
use Patchlevel\Hydrator\Cryptography\Store\CipherKeyStore;

/**
* @phpstan-type EncryptedDataV1 array{
* __enc: 'v1',
* data: non-empty-string,
* method?: non-empty-string,
* iv?: non-empty-string,
* }
*/
final class BaseCryptographer implements Cryptographer
{
public function __construct(
private readonly Cipher $cipher,
private readonly CipherKeyStore $cipherKeyStore,
private readonly CipherKeyFactory $cipherKeyFactory,
)
{
}

/**
* @throws EncryptionFailed
*
* @return EncryptedDataV1
*/
public function encrypt(string $subjectId, mixed $value): array
{
try {
$cipherKey = $this->cipherKeyStore->get($subjectId);
} catch (CipherKeyNotExists) {
$cipherKey = ($this->cipherKeyFactory)();
$this->cipherKeyStore->store($subjectId, $cipherKey);
}

return [
'__enc' => 'v1',
'data' => $this->cipher->encrypt($cipherKey, $value),
'method' => $cipherKey->method,
'iv' => $cipherKey->iv,
];
}

/**
* @param EncryptedDataV1 $encryptedData
*
* @throws CipherKeyNotExists
* @throws DecryptionFailed
*/
public function decrypt(string $subjectId, mixed $encryptedData): mixed
{
$cipherKey = $this->cipherKeyStore->get($subjectId);

return $this->cipher->decrypt(
new CipherKey(
$cipherKey->key,
$encryptedData['method'] ?? $cipherKey->method,
$encryptedData['iv'] ?? $cipherKey->iv
),
$encryptedData['data']
);
}

public function supports(mixed $value): bool
{
return is_array($value) && array_key_exists('__enc', $value) && $value['__enc'] === 'v1';
}

/** @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),
);
}
}
6 changes: 5 additions & 1 deletion src/Cryptography/Cipher/Cipher.php
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,11 @@

interface Cipher
{
/** @throws EncryptionFailed */
/**
* @return non-empty-string
*
* @throws EncryptionFailed
*/
public function encrypt(CipherKey $key, mixed $data): string;

/** @throws DecryptionFailed */
Expand Down
3 changes: 2 additions & 1 deletion src/Cryptography/Cipher/CreateCipherKeyFailed.php
Original file line number Diff line number Diff line change
Expand Up @@ -4,9 +4,10 @@

namespace Patchlevel\Hydrator\Cryptography\Cipher;

use Patchlevel\Hydrator\HydratorException;
use RuntimeException;

final class CreateCipherKeyFailed extends RuntimeException
final class CreateCipherKeyFailed extends RuntimeException implements HydratorException
{
public function __construct()
{
Expand Down
3 changes: 2 additions & 1 deletion src/Cryptography/Cipher/DecryptionFailed.php
Original file line number Diff line number Diff line change
Expand Up @@ -4,9 +4,10 @@

namespace Patchlevel\Hydrator\Cryptography\Cipher;

use Patchlevel\Hydrator\HydratorException;
use RuntimeException;

final class DecryptionFailed extends RuntimeException
final class DecryptionFailed extends RuntimeException implements HydratorException
{
public function __construct()
{
Expand Down
3 changes: 2 additions & 1 deletion src/Cryptography/Cipher/EncryptionFailed.php
Original file line number Diff line number Diff line change
Expand Up @@ -4,9 +4,10 @@

namespace Patchlevel\Hydrator\Cryptography\Cipher;

use Patchlevel\Hydrator\HydratorException;
use RuntimeException;

final class EncryptionFailed extends RuntimeException
final class EncryptionFailed extends RuntimeException implements HydratorException
{
public function __construct()
{
Expand Down
3 changes: 2 additions & 1 deletion src/Cryptography/Cipher/MethodNotSupported.php
Original file line number Diff line number Diff line change
Expand Up @@ -4,11 +4,12 @@

namespace Patchlevel\Hydrator\Cryptography\Cipher;

use Patchlevel\Hydrator\HydratorException;
use RuntimeException;

use function sprintf;

final class MethodNotSupported extends RuntimeException
final class MethodNotSupported extends RuntimeException implements HydratorException
{
public function __construct(string $method)
{
Expand Down
3 changes: 3 additions & 0 deletions src/Cryptography/Cipher/OpensslCipher.php
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,9 @@

final class OpensslCipher implements Cipher
{
/**
* @return non-empty-string
*/
public function encrypt(CipherKey $key, mixed $data): string
{
$encryptedData = @openssl_encrypt(
Expand Down
73 changes: 73 additions & 0 deletions src/Cryptography/CryptoNormalizer.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,73 @@
<?php

namespace Patchlevel\Hydrator\Cryptography;

use Closure;
use Patchlevel\Hydrator\Cryptography\Cipher\DecryptionFailed;
use Patchlevel\Hydrator\Cryptography\Store\CipherKeyNotExists;
use Patchlevel\Hydrator\Normalizer\ContextAwareNormalizer;
use Patchlevel\Hydrator\Normalizer\InvalidArgument;
use Patchlevel\Hydrator\Normalizer\Normalizer;

final class CryptoNormalizer implements Normalizer, ContextAwareNormalizer
{
public function __construct(
private readonly Cryptographer $cryptographer,
private readonly string $subjectIdName,
private readonly mixed $fallback = null,
private readonly Normalizer|null $normalizer = null,
) {
}

/**
* @param array<string, mixed> $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;
}

return $this->cryptographer->encrypt($context[SubjectIds::class]->get($this->subjectIdName), $value);
}

/**
* @param array<string, mixed> $context
*
* @throws InvalidArgument
*/
public function denormalize(mixed $value, array $context = []): mixed
{
if (!$this->cryptographer->supports($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);
} catch (DecryptionFailed|CipherKeyNotExists) {
if ($this->fallback instanceof Closure) {
return ($this->fallback)($subjectId);
}

return $this->fallback;
}

if ($this->normalizer === null) {
return $data;
}

return $this->normalizer->denormalize($data, $context);
}
}
22 changes: 22 additions & 0 deletions src/Cryptography/Cryptographer.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,22 @@
<?php

namespace Patchlevel\Hydrator\Cryptography;

use Patchlevel\Hydrator\Cryptography\Cipher\DecryptionFailed;
use Patchlevel\Hydrator\Cryptography\Cipher\EncryptionFailed;
use Patchlevel\Hydrator\Cryptography\Store\CipherKeyNotExists;

interface Cryptographer
{
/**
* @throws EncryptionFailed
*/
public function encrypt(string $subjectId, mixed $value): mixed;
/**
* @throws CipherKeyNotExists
* @throws DecryptionFailed
*/
public function decrypt(string $subjectId, mixed $encryptedData): mixed;

public function supports(mixed $value): bool;
}
31 changes: 11 additions & 20 deletions src/Cryptography/CryptographyMetadataFactory.php
Original file line number Diff line number Diff line change
Expand Up @@ -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,
) {
}
Expand Down Expand Up @@ -46,17 +46,24 @@ public function metadata(string $class): ClassMetadata
$isSubjectId = true;
}

$sensitiveDataInfo = $this->sensitiveDataInfo($property->reflection);
$attributeReflectionList = $property->reflection->getAttributes(SensitiveData::class);

if (!$sensitiveDataInfo) {
if ($attributeReflectionList === []) {
continue;
}

if ($isSubjectId) {
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 !== []) {
Expand All @@ -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,
);
}
}
Loading