-
Notifications
You must be signed in to change notification settings - Fork 5
Commit
- Loading branch information
There are no files selected for viewing
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,12 @@ | ||
<?php | ||
|
||
declare(strict_types=1); | ||
|
||
namespace Patchlevel\EventSourcing\Attribute; | ||
|
||
use Attribute; | ||
|
||
#[Attribute(Attribute::TARGET_PROPERTY)] | ||
final class DataSubjectId | ||
{ | ||
} |
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,16 @@ | ||
<?php | ||
|
||
declare(strict_types=1); | ||
|
||
namespace Patchlevel\EventSourcing\Attribute; | ||
|
||
use Attribute; | ||
|
||
#[Attribute(Attribute::TARGET_PROPERTY)] | ||
final class PersonalData | ||
{ | ||
public function __construct( | ||
public readonly mixed $fallback = null, | ||
) { | ||
} | ||
} |
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,13 @@ | ||
<?php | ||
|
||
declare(strict_types=1); | ||
|
||
namespace Patchlevel\EventSourcing\Cryptography\Cipher; | ||
|
||
interface Cipher | ||
{ | ||
public function encrypt(CipherKey $key, mixed $data): string; | ||
|
||
/** @throws DecryptionFailed */ | ||
public function decrypt(CipherKey $key, string $data): mixed; | ||
} |
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,20 @@ | ||
<?php | ||
|
||
declare(strict_types=1); | ||
|
||
namespace Patchlevel\EventSourcing\Cryptography\Cipher; | ||
|
||
final class CipherKey | ||
{ | ||
/** | ||
* @param non-empty-string $key | ||
* @param non-empty-string $method | ||
* @param non-empty-string $iv | ||
*/ | ||
public function __construct( | ||
public readonly string $key, | ||
public readonly string $method, | ||
public readonly string $iv, | ||
) { | ||
} | ||
} |
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,10 @@ | ||
<?php | ||
|
||
declare(strict_types=1); | ||
|
||
namespace Patchlevel\EventSourcing\Cryptography\Cipher; | ||
|
||
interface CipherKeyFactory | ||
{ | ||
public function __invoke(): CipherKey; | ||
} |
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,15 @@ | ||
<?php | ||
|
||
declare(strict_types=1); | ||
|
||
namespace Patchlevel\EventSourcing\Cryptography\Cipher; | ||
|
||
use RuntimeException; | ||
|
||
final class DecryptionFailed extends RuntimeException | ||
{ | ||
public function __construct() | ||
{ | ||
parent::__construct('decryption failed'); | ||
} | ||
} |
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,57 @@ | ||
<?php | ||
|
||
declare(strict_types=1); | ||
|
||
namespace Patchlevel\EventSourcing\Cryptography\Cipher; | ||
|
||
use function base64_decode; | ||
use function base64_encode; | ||
use function json_decode; | ||
use function json_encode; | ||
use function openssl_decrypt; | ||
use function openssl_encrypt; | ||
|
||
use const JSON_THROW_ON_ERROR; | ||
|
||
final class OpensslCipher implements Cipher | ||
{ | ||
public function encrypt(CipherKey $key, mixed $data): string | ||
{ | ||
return base64_encode( | ||
openssl_encrypt( | ||
$this->dataEncode($data), | ||
$key->method, | ||
$key->key, | ||
0, | ||
$key->iv, | ||
), | ||
); | ||
} | ||
|
||
public function decrypt(CipherKey $key, string $data): mixed | ||
{ | ||
$data = openssl_decrypt( | ||
base64_decode($data), | ||
$key->method, | ||
$key->key, | ||
0, | ||
$key->iv, | ||
); | ||
|
||
if ($data === false) { | ||
throw new DecryptionFailed(); | ||
} | ||
|
||
return $this->dataDecode($data); | ||
} | ||
|
||
private function dataEncode(mixed $data): string | ||
{ | ||
return json_encode($data, JSON_THROW_ON_ERROR); | ||
} | ||
|
||
private function dataDecode(string $data): mixed | ||
{ | ||
return json_decode($data, true, 512, JSON_THROW_ON_ERROR); | ||
} | ||
} |
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,30 @@ | ||
<?php | ||
|
||
declare(strict_types=1); | ||
|
||
namespace Patchlevel\EventSourcing\Cryptography\Cipher; | ||
|
||
use function openssl_cipher_iv_length; | ||
use function openssl_random_pseudo_bytes; | ||
|
||
final class OpensslCipherKeyFactory implements CipherKeyFactory | ||
{ | ||
public const DEFAULT_LENGTH = 32; | ||
|
||
public const DEFAULT_METHOD = 'aes128'; | ||
|
||
public function __construct( | ||
private readonly int $length = self::DEFAULT_LENGTH, | ||
private readonly string $method = self::DEFAULT_METHOD, | ||
) { | ||
} | ||
|
||
public function __invoke(): CipherKey | ||
{ | ||
return new CipherKey( | ||
openssl_random_pseudo_bytes($this->length), | ||
Check failure on line 25 in src/Cryptography/Cipher/OpensslCipherKeyFactory.php GitHub Actions / Static Analysis by PHPStan (locked, 8.3, ubuntu-latest)
Check failure on line 25 in src/Cryptography/Cipher/OpensslCipherKeyFactory.php GitHub Actions / Static Analysis by Psalm (locked, 8.3, ubuntu-latest)ArgumentTypeCoercion
|
||
$this->method, | ||
Check failure on line 26 in src/Cryptography/Cipher/OpensslCipherKeyFactory.php GitHub Actions / Static Analysis by PHPStan (locked, 8.3, ubuntu-latest)
Check failure on line 26 in src/Cryptography/Cipher/OpensslCipherKeyFactory.php GitHub Actions / Static Analysis by Psalm (locked, 8.3, ubuntu-latest)ArgumentTypeCoercion
|
||
openssl_random_pseudo_bytes(openssl_cipher_iv_length($this->method)), | ||
Check failure on line 27 in src/Cryptography/Cipher/OpensslCipherKeyFactory.php GitHub Actions / Static Analysis by PHPStan (locked, 8.3, ubuntu-latest)
Check failure on line 27 in src/Cryptography/Cipher/OpensslCipherKeyFactory.php GitHub Actions / Static Analysis by PHPStan (locked, 8.3, ubuntu-latest)
Check failure on line 27 in src/Cryptography/Cipher/OpensslCipherKeyFactory.php GitHub Actions / Static Analysis by Psalm (locked, 8.3, ubuntu-latest)ArgumentTypeCoercion
|
||
); | ||
} | ||
} |
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,132 @@ | ||
<?php | ||
|
||
declare(strict_types=1); | ||
|
||
namespace Patchlevel\EventSourcing\Cryptography; | ||
|
||
use Patchlevel\EventSourcing\Cryptography\Cipher\Cipher; | ||
use Patchlevel\EventSourcing\Cryptography\Cipher\CipherKeyFactory; | ||
use Patchlevel\EventSourcing\Cryptography\Cipher\DecryptionFailed; | ||
use Patchlevel\EventSourcing\Cryptography\Cipher\OpensslCipher; | ||
use Patchlevel\EventSourcing\Cryptography\Cipher\OpensslCipherKeyFactory; | ||
use Patchlevel\EventSourcing\Cryptography\Store\CipherKeyNotExists; | ||
use Patchlevel\EventSourcing\Cryptography\Store\CipherKeyStore; | ||
use Patchlevel\EventSourcing\Metadata\Event\EventMetadataFactory; | ||
|
||
final class DefaultEventPayloadCryptographer implements EventPayloadCryptographer | ||
{ | ||
public function __construct( | ||
private readonly EventMetadataFactory $eventMetadataFactory, | ||
private readonly CipherKeyStore $cryptoStore, | ||
private readonly Cipher $cipher, | ||
private readonly CipherKeyFactory $cipherKeyFactory, | ||
) { | ||
} | ||
|
||
/** | ||
* @param class-string $class | ||
* @param array<string, mixed> $data | ||
* | ||
* @return array<string, mixed> | ||
*/ | ||
public function encrypt(string $class, array $data): array | ||
{ | ||
$subjectId = $this->subjectId($class, $data); | ||
|
||
if ($subjectId === null) { | ||
return $data; | ||
} | ||
|
||
try { | ||
$cipherKey = $this->cryptoStore->get($subjectId); | ||
} catch (CipherKeyNotExists) { | ||
$cipherKey = ($this->cipherKeyFactory)(); | ||
$this->cryptoStore->store($subjectId, $cipherKey); | ||
} | ||
|
||
$metadata = $this->eventMetadataFactory->metadata($class); | ||
|
||
foreach ($metadata->propertyMetadata as $propertyMetadata) { | ||
if (!$propertyMetadata->isPersonalData) { | ||
continue; | ||
} | ||
|
||
$data[$propertyMetadata->fieldName] = $this->cipher->encrypt( | ||
$cipherKey, | ||
$data[$propertyMetadata->fieldName], | ||
); | ||
} | ||
|
||
return $data; | ||
} | ||
|
||
/** | ||
* @param class-string $class | ||
* @param array<string, mixed> $data | ||
* | ||
* @return array<string, mixed> | ||
*/ | ||
public function decrypt(string $class, array $data): array | ||
{ | ||
$subjectId = $this->subjectId($class, $data); | ||
|
||
if ($subjectId === null) { | ||
return $data; | ||
} | ||
|
||
try { | ||
$cipherKey = $this->cryptoStore->get($subjectId); | ||
} catch (CipherKeyNotExists) { | ||
$cipherKey = null; | ||
} | ||
|
||
$metadata = $this->eventMetadataFactory->metadata($class); | ||
|
||
foreach ($metadata->propertyMetadata as $propertyMetadata) { | ||
if (!$propertyMetadata->isPersonalData) { | ||
continue; | ||
} | ||
|
||
if (!$cipherKey) { | ||
$data[$propertyMetadata->fieldName] = $propertyMetadata->personalDataFallback; | ||
Check failure on line 91 in src/Cryptography/DefaultEventPayloadCryptographer.php GitHub Actions / Static Analysis by Psalm (locked, 8.3, ubuntu-latest)MixedAssignment
|
||
continue; | ||
} | ||
|
||
try { | ||
$data[$propertyMetadata->fieldName] = $this->cipher->decrypt( | ||
Check failure on line 96 in src/Cryptography/DefaultEventPayloadCryptographer.php GitHub Actions / Static Analysis by Psalm (locked, 8.3, ubuntu-latest)MixedAssignment
|
||
$cipherKey, | ||
$data[$propertyMetadata->fieldName], | ||
Check failure on line 98 in src/Cryptography/DefaultEventPayloadCryptographer.php GitHub Actions / Static Analysis by PHPStan (locked, 8.3, ubuntu-latest)
Check failure on line 98 in src/Cryptography/DefaultEventPayloadCryptographer.php GitHub Actions / Static Analysis by Psalm (locked, 8.3, ubuntu-latest)MixedArgument
|
||
); | ||
} catch (DecryptionFailed) { | ||
$data[$propertyMetadata->fieldName] = $propertyMetadata->personalDataFallback; | ||
Check failure on line 101 in src/Cryptography/DefaultEventPayloadCryptographer.php GitHub Actions / Static Analysis by Psalm (locked, 8.3, ubuntu-latest)MixedAssignment
|
||
} | ||
} | ||
|
||
return $data; | ||
} | ||
|
||
/** | ||
* @param class-string $class | ||
* @param array<string, mixed> $data | ||
*/ | ||
private function subjectId(string $class, array $data): string|null | ||
Check failure on line 112 in src/Cryptography/DefaultEventPayloadCryptographer.php GitHub Actions / Static Analysis by Psalm (locked, 8.3, ubuntu-latest)MixedInferredReturnType
|
||
{ | ||
$metadata = $this->eventMetadataFactory->metadata($class); | ||
|
||
if ($metadata->dataSubjectIdField === null) { | ||
return null; | ||
} | ||
|
||
return $data[$metadata->dataSubjectIdField]; | ||
Check failure on line 120 in src/Cryptography/DefaultEventPayloadCryptographer.php GitHub Actions / Static Analysis by PHPStan (locked, 8.3, ubuntu-latest)
Check failure on line 120 in src/Cryptography/DefaultEventPayloadCryptographer.php GitHub Actions / Static Analysis by Psalm (locked, 8.3, ubuntu-latest)MixedReturnStatement
|
||
} | ||
|
||
public static function createWithOpenssl(EventMetadataFactory $eventMetadataFactory, CipherKeyStore $cryptoStore): static | ||
{ | ||
return new self( | ||
$eventMetadataFactory, | ||
$cryptoStore, | ||
new OpensslCipher(), | ||
new OpensslCipherKeyFactory(), | ||
); | ||
} | ||
} |
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,24 @@ | ||
<?php | ||
|
||
declare(strict_types=1); | ||
|
||
namespace Patchlevel\EventSourcing\Cryptography; | ||
|
||
interface EventPayloadCryptographer | ||
{ | ||
/** | ||
* @param class-string $class | ||
* @param array<string, mixed> $data | ||
* | ||
* @return array<string, mixed> | ||
*/ | ||
public function encrypt(string $class, array $data): array; | ||
|
||
/** | ||
* @param class-string $class | ||
* @param array<string, mixed> $data | ||
* | ||
* @return array<string, mixed> | ||
*/ | ||
public function decrypt(string $class, array $data): array; | ||
} |
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,17 @@ | ||
<?php | ||
|
||
declare(strict_types=1); | ||
|
||
namespace Patchlevel\EventSourcing\Cryptography\Store; | ||
|
||
use RuntimeException; | ||
|
||
use function sprintf; | ||
|
||
final class CipherKeyNotExists extends RuntimeException | ||
{ | ||
public function __construct(string $id) | ||
{ | ||
parent::__construct(sprintf('Cipher key with subject id "%s" not found', $id)); | ||
} | ||
} |
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,17 @@ | ||
<?php | ||
|
||
declare(strict_types=1); | ||
|
||
namespace Patchlevel\EventSourcing\Cryptography\Store; | ||
|
||
use Patchlevel\EventSourcing\Cryptography\Cipher\CipherKey; | ||
|
||
interface CipherKeyStore | ||
{ | ||
/** @throws CipherKeyNotExists */ | ||
public function get(string $id): CipherKey; | ||
|
||
public function store(string $id, CipherKey $key): void; | ||
|
||
public function remove(string $id): void; | ||
} |