Cryptography

The cryptography extension can encrypt and decrypt sensitive data, e.g. personal data of customers. For each subject (e.g. a person) a separate cipher key is created and used to encrypt the marked fields. If the key is deleted, the data becomes unreadable. This pattern is known as crypto-shredding and makes "forgetting" a person possible even in immutable storage.

The cryptography extension is experimental and may change in a minor release.

Setup

Register the CryptographyExtension on the builder and pass it a Cryptographer. The BaseCryptographer with the openssl cipher is the default choice; it needs a cipher key store to keep the keys.

use Patchlevel\Hydrator\CoreExtension;
use Patchlevel\Hydrator\Extension\Cryptography\BaseCryptographer;
use Patchlevel\Hydrator\Extension\Cryptography\CryptographyExtension;
use Patchlevel\Hydrator\Extension\Cryptography\Store\InMemoryCipherKeyStore;
use Patchlevel\Hydrator\StackHydratorBuilder;

$cipherKeyStore = new InMemoryCipherKeyStore();

$hydrator = (new StackHydratorBuilder())
    ->useExtension(new CoreExtension())
    ->useExtension(new CryptographyExtension(BaseCryptographer::createWithOpenssl($cipherKeyStore)))
    ->build();

DataSubjectId

First you need to define which field identifies the subject the data belongs to. The cipher key is created and looked up per subject id.

use Patchlevel\Hydrator\Extension\Cryptography\Attribute\DataSubjectId;
use Patchlevel\Hydrator\Extension\Cryptography\Attribute\SensitiveData;

final class EmailChanged
{
    public function __construct(
        #[DataSubjectId]
        public readonly string $profileId,
        #[SensitiveData]
        public readonly string|null $email,
    ) {
    }
}

The DataSubjectId must be a string, you can use a normalizer to convert a value object to a string. The subject id itself cannot be sensitive data.

You can also use multiple subject ids in one class by naming them and referencing the name from the sensitive fields. The default name is default.

use Patchlevel\Hydrator\Extension\Cryptography\Attribute\DataSubjectId;
use Patchlevel\Hydrator\Extension\Cryptography\Attribute\SensitiveData;

final class ProfilesMerged
{
    public function __construct(
        #[DataSubjectId(name: 'source')]
        public readonly string $sourceProfileId,
        #[SensitiveData(subjectIdName: 'source')]
        public readonly string|null $sourceEmail,
        #[DataSubjectId(name: 'target')]
        public readonly string $targetProfileId,
        #[SensitiveData(subjectIdName: 'target')]
        public readonly string|null $targetEmail,
    ) {
    }
}

Fallback values

If the data could not be decrypted, because the key has been removed, a fallback value is inserted. The default fallback is null. You can change this with the fallback parameter:

use Patchlevel\Hydrator\Extension\Cryptography\Attribute\DataSubjectId;
use Patchlevel\Hydrator\Extension\Cryptography\Attribute\SensitiveData;

final class ProfileCreated
{
    public function __construct(
        #[DataSubjectId]
        public readonly string $profileId,
        #[SensitiveData(fallback: 'unknown')]
        public readonly string $name,
    ) {
    }
}

You can also use a callable as a fallback. It receives the subject id:

use Patchlevel\Hydrator\Extension\Cryptography\Attribute\DataSubjectId;
use Patchlevel\Hydrator\Extension\Cryptography\Attribute\SensitiveData;

final class ProfileCreated
{
    public function __construct(
        #[DataSubjectId]
        public readonly string $profileId,
        #[SensitiveData(fallback: 'deleted profile')]
        public readonly string $name,
        #[SensitiveData(fallbackCallable: [self::class, 'anonymizedEmail'])]
        public readonly string $email,
    ) {
    }

    public static function anonymizedEmail(string $subjectId): string
    {
        return sprintf('%s@anonymized.example', $subjectId);
    }
}

fallback and fallbackCallable are mutually exclusive, setting both throws an exception.

Cipher Key Store

The cipher keys must be stored somewhere. For testing purposes there is an in-memory implementation:

use Patchlevel\Hydrator\Extension\Cryptography\Store\InMemoryCipherKeyStore;

$cipherKeyStore = new InMemoryCipherKeyStore();

For production you have to implement the CipherKeyStore interface yourself, backed by a database or a key management service, because only you know where the keys should live:

namespace Patchlevel\Hydrator\Extension\Cryptography\Store;

use Patchlevel\Hydrator\Extension\Cryptography\Cipher\CipherKey;

interface CipherKeyStore
{
    /** @throws CipherKeyNotExists */
    public function currentKeyFor(string $subjectId): CipherKey;

    /** @throws CipherKeyNotExists */
    public function get(string $id): CipherKey;

    public function store(CipherKey $key): void;

    public function remove(string $id): void;

    public function removeWithSubjectId(string $subjectId): void;
}

To avoid hitting your key storage for every operation, you can wrap the store in one of the cache decorators:

use Patchlevel\Hydrator\Extension\Cryptography\Store\Psr6CacheStoreDecorator;
use Patchlevel\Hydrator\Extension\Cryptography\Store\Psr16CacheStoreDecorator;

$cipherKeyStore = new Psr6CacheStoreDecorator($myDatabaseStore, $psr6CachePool);
// or
$cipherKeyStore = new Psr16CacheStoreDecorator($myDatabaseStore, $psr16Cache);

Remove personal data

To remove personal data, you only need to remove the keys for the subject from the store. All encrypted fields of that subject then resolve to their fallback values.

$cipherKeyStore->removeWithSubjectId('profile-1');

Removing a cipher key is irreversible. The encrypted data can never be decrypted again, that is the point of crypto-shredding, but make sure it is what you want.

Cryptography is very expensive in terms of performance. You can combine it with lazy objects so the data is only decrypted when the object is actually accessed.

Learn more