diff --git a/.env.test b/.env.test index a5f73474d..271dd20c3 100644 --- a/.env.test +++ b/.env.test @@ -2,3 +2,4 @@ KERNEL_CLASS='App\Kernel' APP_SECRET='$ecretf0rt3st' DATABASE_URL=sqlite:///%kernel.project_dir%/data/database_test.sqlite +APP_EMAIL_ENCRYPTION_KEY=0000000000000000000000000000000000000000000000000000000000000000 diff --git a/README.md b/README.md index b217ae2b3..60a01041c 100644 --- a/README.md +++ b/README.md @@ -73,6 +73,23 @@ cd my_project/ ./bin/phpunit ``` +> **Note:** User emails are encrypted in use using a key generated automatically +> in `.env.local` during `composer install`. If you regenerate the key (e.g. by +> deleting `.env.local` and running `composer install` again), you **must** reload +> the fixtures so the database is re-encrypted with the new key: +> +> ```bash +> php bin/console doctrine:fixtures:load +> ``` +> +> The test database (`data/database_test.sqlite`) uses the fixed key defined in +> `.env.test` (`APP_EMAIL_ENCRYPTION_KEY=000...000`). After changing the test +> fixtures or the encryption logic, regenerate it with: +> +> ```bash +> php bin/console doctrine:fixtures:load --env=test +> ``` + [1]: https://symfony.com/doc/current/best_practices.html [2]: https://symfony.com/doc/current/setup.html#technical-requirements [3]: https://symfony.com/doc/current/setup/web_server_configuration.html diff --git a/bin/generate-env-keys b/bin/generate-env-keys new file mode 100755 index 000000000..ae4fd4045 --- /dev/null +++ b/bin/generate-env-keys @@ -0,0 +1,36 @@ +#!/usr/bin/env php + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +/** + * Generates missing secret keys in .env.local. + * Called automatically on composer install/update. + */ + +$envLocalPath = dirname(__DIR__).'/.env.local'; +$content = file_exists($envLocalPath) ? file_get_contents($envLocalPath) : ''; + +$keys = [ + 'APP_EMAIL_ENCRYPTION_KEY' => static fn () => bin2hex(random_bytes(32)), +]; + +$added = []; +foreach ($keys as $key => $generator) { + if (!str_contains($content, $key.'=')) { + $content .= PHP_EOL.$key.'='.$generator().PHP_EOL; + $added[] = $key; + } +} + +if ($added !== []) { + file_put_contents($envLocalPath, $content); + echo 'Generated missing keys in .env.local: '.implode(', ', $added).PHP_EOL; +} diff --git a/composer.json b/composer.json index bd76429ec..03dddac87 100644 --- a/composer.json +++ b/composer.json @@ -3,16 +3,21 @@ "license": "MIT", "type": "project", "description": "Symfony Demo Application", - "minimum-stability": "stable", + "minimum-stability": "dev", "prefer-stable": true, + "repositories": [ + {"type": "vcs", "url": "https://github.com/GromNaN/DoctrineBundle.git"}, + {"type": "vcs", "url": "https://github.com/GromNaN/dbal.git"}, + {"type": "vcs", "url": "https://github.com/GromNaN/doctrine-orm.git"} + ], "require": { "php": ">=8.4", "ext-ctype": "*", "ext-iconv": "*", "ext-pdo_sqlite": "*", - "doctrine/dbal": "^4.0", - "doctrine/doctrine-bundle": "^3.0", - "doctrine/orm": "^3.5", + "doctrine/dbal": "dev-type-registry-config as 4.4.x-dev", + "doctrine/doctrine-bundle": "dev-feat/dbal-type-as-service as 3.3.x-dev", + "doctrine/orm": "dev-type-registry-instance as 3.6.x-dev", "league/commonmark": "^2.1", "symfony/apache-pack": "^1.0", "symfony/asset": "^8", @@ -98,6 +103,7 @@ "scripts": { "auto-scripts": { "-r \"@rename('.env.local.demo', '.env.local');\"": "php-script", + "bin/generate-env-keys": "script", "cache:clear": "symfony-cmd", "assets:install %PUBLIC_DIR%": "symfony-cmd", "importmap:install": "symfony-cmd", diff --git a/composer.lock b/composer.lock index 8602309ff..9f7cd4a3e 100644 --- a/composer.lock +++ b/composer.lock @@ -4,7 +4,7 @@ "Read more about it at https://getcomposer.org/doc/01-basic-usage.md#installing-dependencies", "This file is @generated automatically" ], - "content-hash": "4f317f19687aac0583e2af41e20edaf7", + "content-hash": "770ae02bb45e97c56567b18e0b57409d", "packages": [ { "name": "composer/semver", @@ -246,16 +246,16 @@ }, { "name": "doctrine/dbal", - "version": "4.4.1", + "version": "dev-type-registry-config", "source": { "type": "git", - "url": "https://github.com/doctrine/dbal.git", - "reference": "3d544473fb93f5c25b483ea4f4ce99f8c4d9d44c" + "url": "https://github.com/GromNaN/dbal.git", + "reference": "adbd44c2a6f3b81ebaac100a0455df38329433e3" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/doctrine/dbal/zipball/3d544473fb93f5c25b483ea4f4ce99f8c4d9d44c", - "reference": "3d544473fb93f5c25b483ea4f4ce99f8c4d9d44c", + "url": "https://api.github.com/repos/GromNaN/dbal/zipball/adbd44c2a6f3b81ebaac100a0455df38329433e3", + "reference": "adbd44c2a6f3b81ebaac100a0455df38329433e3", "shasum": "" }, "require": { @@ -271,9 +271,9 @@ "phpstan/phpstan": "2.1.30", "phpstan/phpstan-phpunit": "2.0.7", "phpstan/phpstan-strict-rules": "^2", - "phpunit/phpunit": "11.5.23", - "slevomat/coding-standard": "8.24.0", - "squizlabs/php_codesniffer": "4.0.0", + "phpunit/phpunit": "11.5.50", + "slevomat/coding-standard": "8.27.1", + "squizlabs/php_codesniffer": "4.0.1", "symfony/cache": "^6.3.8|^7.0|^8.0", "symfony/console": "^5.4|^6.3|^7.0|^8.0" }, @@ -286,7 +286,16 @@ "Doctrine\\DBAL\\": "src" } }, - "notification-url": "https://packagist.org/downloads/", + "autoload-dev": { + "psr-4": { + "Doctrine\\DBAL\\Tests\\": "tests" + } + }, + "scripts": { + "docs": [ + "composer --working-dir docs update && ./docs/vendor/bin/build-docs.sh @additional_args" + ] + }, "license": [ "MIT" ], @@ -331,24 +340,23 @@ "sqlsrv" ], "support": { - "issues": "https://github.com/doctrine/dbal/issues", - "source": "https://github.com/doctrine/dbal/tree/4.4.1" + "source": "https://github.com/GromNaN/dbal/tree/type-registry-config" }, "funding": [ { - "url": "https://www.doctrine-project.org/sponsorship.html", - "type": "custom" + "type": "patreon", + "url": "https://www.patreon.com/phpdoctrine" }, { - "url": "https://www.patreon.com/phpdoctrine", - "type": "patreon" + "type": "tidelift", + "url": "https://tidelift.com/funding/github/packagist/doctrine%2Fdbal" }, { - "url": "https://tidelift.com/funding/github/packagist/doctrine%2Fdbal", - "type": "tidelift" + "type": "custom", + "url": "https://www.doctrine-project.org/sponsorship.html" } ], - "time": "2025-12-04T10:11:03+00:00" + "time": "2026-04-03T10:47:37+00:00" }, { "name": "doctrine/deprecations", @@ -400,16 +408,16 @@ }, { "name": "doctrine/doctrine-bundle", - "version": "3.2.2", + "version": "dev-feat/dbal-type-as-service", "source": { "type": "git", - "url": "https://github.com/doctrine/DoctrineBundle.git", - "reference": "af84173db6978c3d2688ea3bcf3a91720b0704ce" + "url": "https://github.com/GromNaN/DoctrineBundle.git", + "reference": "480b5af8b5731a3c81b8cc8bc5fcbbb0132ff042" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/doctrine/DoctrineBundle/zipball/af84173db6978c3d2688ea3bcf3a91720b0704ce", - "reference": "af84173db6978c3d2688ea3bcf3a91720b0704ce", + "url": "https://api.github.com/repos/GromNaN/DoctrineBundle/zipball/480b5af8b5731a3c81b8cc8bc5fcbbb0132ff042", + "reference": "480b5af8b5731a3c81b8cc8bc5fcbbb0132ff042", "shasum": "" }, "require": { @@ -433,14 +441,15 @@ "require-dev": { "doctrine/coding-standard": "^14", "doctrine/orm": "^3.4.4", - "phpstan/phpstan": "2.1.1", + "phpstan/phpstan": "^2.1.13", "phpstan/phpstan-phpunit": "2.0.3", "phpstan/phpstan-strict-rules": "^2", - "phpstan/phpstan-symfony": "^2.0", + "phpstan/phpstan-symfony": "^2.0.9", "phpunit/phpunit": "^12.3.10", "psr/log": "^3.0", "symfony/doctrine-messenger": "^6.4 || ^7.0 || ^8.0", "symfony/expression-language": "^6.4 || ^7.0 || ^8.0", + "symfony/http-kernel": "^6.4 || ^7.0 || ^8.0", "symfony/messenger": "^6.4 || ^7.0 || ^8.0", "symfony/property-info": "^6.4 || ^7.0 || ^8.0", "symfony/security-bundle": "^6.4 || ^7.0 || ^8.0", @@ -463,7 +472,18 @@ "Doctrine\\Bundle\\DoctrineBundle\\": "src" } }, - "notification-url": "https://packagist.org/downloads/", + "autoload-dev": { + "psr-4": { + "Doctrine\\Bundle\\DoctrineBundle\\Tests\\": "tests", + "Fixtures\\": "tests/DependencyInjection/Fixtures" + } + }, + "scripts": { + "auto-scripts": { + "cache:clear": "symfony-cmd", + "assets:install %PUBLIC_DIR%": "symfony-cmd" + } + }, "license": [ "MIT" ], @@ -488,30 +508,15 @@ "description": "Symfony DoctrineBundle", "homepage": "https://www.doctrine-project.org", "keywords": [ - "database", - "dbal", - "orm", - "persistence" + "DBAL", + "Database", + "ORM", + "Persistence" ], "support": { - "issues": "https://github.com/doctrine/DoctrineBundle/issues", - "source": "https://github.com/doctrine/DoctrineBundle/tree/3.2.2" + "source": "https://github.com/GromNaN/DoctrineBundle/tree/feat/dbal-type-as-service" }, - "funding": [ - { - "url": "https://www.doctrine-project.org/sponsorship.html", - "type": "custom" - }, - { - "url": "https://www.patreon.com/phpdoctrine", - "type": "patreon" - }, - { - "url": "https://tidelift.com/funding/github/packagist/doctrine%2Fdoctrine-bundle", - "type": "tidelift" - } - ], - "time": "2025-12-24T12:24:29+00:00" + "time": "2026-04-03T11:28:08+00:00" }, { "name": "doctrine/event-manager", @@ -842,24 +847,24 @@ }, { "name": "doctrine/orm", - "version": "3.6.2", + "version": "dev-type-registry-instance", "source": { "type": "git", - "url": "https://github.com/doctrine/orm.git", - "reference": "4262eb495b4d2a53b45de1ac58881e0091f2970f" + "url": "https://github.com/GromNaN/doctrine-orm.git", + "reference": "460fdc90a9c63b67f5aeb39f16e0a07c099d122c" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/doctrine/orm/zipball/4262eb495b4d2a53b45de1ac58881e0091f2970f", - "reference": "4262eb495b4d2a53b45de1ac58881e0091f2970f", + "url": "https://api.github.com/repos/GromNaN/doctrine-orm/zipball/460fdc90a9c63b67f5aeb39f16e0a07c099d122c", + "reference": "460fdc90a9c63b67f5aeb39f16e0a07c099d122c", "shasum": "" }, "require": { "composer-runtime-api": "^2", - "doctrine/collections": "^2.2", + "doctrine/collections": "^2.2 || ^3", "doctrine/dbal": "^3.8.2 || ^4", "doctrine/deprecations": "^0.5.3 || ^1", - "doctrine/event-manager": "^1.2 || ^2", + "doctrine/event-manager": "^2.1.1", "doctrine/inflector": "^1.4 || ^2.0", "doctrine/instantiator": "^1.3 || ^2", "doctrine/lexer": "^3", @@ -890,7 +895,18 @@ "Doctrine\\ORM\\": "src" } }, - "notification-url": "https://packagist.org/downloads/", + "autoload-dev": { + "psr-4": { + "Doctrine\\Performance\\": "tests/Performance", + "Doctrine\\StaticAnalysis\\": "tests/StaticAnalysis", + "Doctrine\\Tests\\": "tests/Tests" + } + }, + "scripts": { + "docs": [ + "composer --working-dir docs update && ./docs/vendor/bin/build-docs.sh @additional_args" + ] + }, "license": [ "MIT" ], @@ -923,10 +939,9 @@ "orm" ], "support": { - "issues": "https://github.com/doctrine/orm/issues", - "source": "https://github.com/doctrine/orm/tree/3.6.2" + "source": "https://github.com/GromNaN/doctrine-orm/tree/type-registry-instance" }, - "time": "2026-01-30T21:41:41+00:00" + "time": "2026-04-03T08:26:28+00:00" }, { "name": "doctrine/persistence", @@ -11515,9 +11530,32 @@ "time": "2025-11-17T20:03:58+00:00" } ], - "aliases": [], - "minimum-stability": "stable", - "stability-flags": {}, + "aliases": [ + { + "package": "doctrine/dbal", + "version": "dev-type-registry-config", + "alias": "4.4.x-dev", + "alias_normalized": "4.4.9999999.9999999-dev" + }, + { + "package": "doctrine/doctrine-bundle", + "version": "dev-feat/dbal-type-as-service", + "alias": "3.3.x-dev", + "alias_normalized": "3.3.9999999.9999999-dev" + }, + { + "package": "doctrine/orm", + "version": "dev-type-registry-instance", + "alias": "3.6.x-dev", + "alias_normalized": "3.6.9999999.9999999-dev" + } + ], + "minimum-stability": "dev", + "stability-flags": { + "doctrine/dbal": 20, + "doctrine/doctrine-bundle": 20, + "doctrine/orm": 20 + }, "prefer-stable": true, "prefer-lowest": false, "platform": { @@ -11530,5 +11568,5 @@ "platform-overrides": { "php": "8.4.0" }, - "plugin-api-version": "2.6.0" + "plugin-api-version": "2.9.0" } diff --git a/data/database.sqlite b/data/database.sqlite index cbd2bf8b2..52a57aa02 100644 Binary files a/data/database.sqlite and b/data/database.sqlite differ diff --git a/data/database_test.sqlite b/data/database_test.sqlite index 1bdf92e07..947809597 100644 Binary files a/data/database_test.sqlite and b/data/database_test.sqlite differ diff --git a/src/DataFixtures/AppFixtures.php b/src/DataFixtures/AppFixtures.php index f5bd9297b..07902677b 100644 --- a/src/DataFixtures/AppFixtures.php +++ b/src/DataFixtures/AppFixtures.php @@ -85,7 +85,7 @@ private function loadPosts(ObjectManager $manager): void $comment = new Comment(); $comment->setAuthor($this->getReference('john_user', User::class)); $comment->setContent($this->getRandomText(random_int(255, 512))); - $comment->setPublishedAt(new \DateTimeImmutable('now + '.$i.'seconds')); + $comment->setPublishedAt($publishedAt->modify('-'.$i.'hours')); $post->addComment($comment); } diff --git a/src/Doctrine/Type/EncryptedEmailType.php b/src/Doctrine/Type/EncryptedEmailType.php new file mode 100644 index 000000000..108779c77 --- /dev/null +++ b/src/Doctrine/Type/EncryptedEmailType.php @@ -0,0 +1,72 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace App\Doctrine\Type; + +use Doctrine\Bundle\DoctrineBundle\Attribute\AsDatabaseType; +use Doctrine\DBAL\Platforms\AbstractPlatform; +use Doctrine\DBAL\Types\Type; +use Symfony\Component\DependencyInjection\Attribute\Autowire; + +#[AsDatabaseType(name: EncryptedEmailType::NAME)] +final class EncryptedEmailType extends Type +{ + public const string NAME = 'encrypted_email'; + + private string $key; + + public function __construct( + #[Autowire(env: 'APP_EMAIL_ENCRYPTION_KEY')] + string $emailEncryptionKey, + ) { + $this->key = hex2bin($emailEncryptionKey); + } + + public function getSQLDeclaration(array $column, AbstractPlatform $platform): string + { + $column['length'] ??= 255; + + return $platform->getStringTypeDeclarationSQL($column); + } + + public function convertToDatabaseValue(mixed $value, AbstractPlatform $platform): ?string + { + if (null === $value) { + return null; + } + + $nonce = substr(hash_hmac('sha256', $value, $this->key, true), 0, 12); + $tag = ''; + $ciphertext = openssl_encrypt($value, 'aes-256-gcm', $this->key, OPENSSL_RAW_DATA, $nonce, $tag, '', 16); + + return base64_encode($nonce.$tag.$ciphertext); + } + + public function convertToPHPValue(mixed $value, AbstractPlatform $platform): ?string + { + if (null === $value) { + return null; + } + + $raw = base64_decode($value, true); + if (false === $raw || \strlen($raw) < 28) { + return null; + } + + $nonce = substr($raw, 0, 12); + $tag = substr($raw, 12, 16); + $ciphertext = substr($raw, 28); + + $plaintext = openssl_decrypt($ciphertext, 'aes-256-gcm', $this->key, OPENSSL_RAW_DATA, $nonce, $tag); + + return false !== $plaintext ? $plaintext : null; + } +} diff --git a/src/Entity/User.php b/src/Entity/User.php index 1253764f2..6f77ec43c 100644 --- a/src/Entity/User.php +++ b/src/Entity/User.php @@ -11,6 +11,7 @@ namespace App\Entity; +use App\Doctrine\Type\EncryptedEmailType; use App\Repository\UserRepository; use Doctrine\DBAL\Types\Types; use Doctrine\ORM\Mapping as ORM; @@ -50,7 +51,7 @@ class User implements UserInterface, PasswordAuthenticatedUserInterface #[Assert\Length(min: 2, max: 50)] private ?string $username = null; - #[ORM\Column(type: Types::STRING, unique: true)] + #[ORM\Column(type: EncryptedEmailType::NAME, unique: true)] #[Assert\Email] private ?string $email = null; diff --git a/tests/Doctrine/Type/EncryptedEmailTypeTest.php b/tests/Doctrine/Type/EncryptedEmailTypeTest.php new file mode 100644 index 000000000..96041dbe1 --- /dev/null +++ b/tests/Doctrine/Type/EncryptedEmailTypeTest.php @@ -0,0 +1,93 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace App\Tests\Doctrine\Type; + +use App\Doctrine\Type\EncryptedEmailType; +use Doctrine\DBAL\Platforms\AbstractPlatform; +use PHPUnit\Framework\Attributes\DataProvider; +use PHPUnit\Framework\TestCase; + +final class EncryptedEmailTypeTest extends TestCase +{ + private EncryptedEmailType $type; + private AbstractPlatform $platform; + + protected function setUp(): void + { + $this->type = new EncryptedEmailType(bin2hex(random_bytes(32))); + $this->platform = $this->createMock(AbstractPlatform::class); + } + + public function testEncodeDecodeRoundtrip(): void + { + $email = 'user@example.com'; + + $encoded = $this->type->convertToDatabaseValue($email, $this->platform); + $decoded = $this->type->convertToPHPValue($encoded, $this->platform); + + self::assertSame($email, $decoded); + } + + public function testNullRoundtrip(): void + { + self::assertNull($this->type->convertToDatabaseValue(null, $this->platform)); + self::assertNull($this->type->convertToPHPValue(null, $this->platform)); + } + + public function testEncryptionIsDeterministic(): void + { + $email = 'user@example.com'; + + $first = $this->type->convertToDatabaseValue($email, $this->platform); + $second = $this->type->convertToDatabaseValue($email, $this->platform); + + self::assertSame($first, $second); + } + + public function testDifferentEmailsProduceDifferentCiphertexts(): void + { + $first = $this->type->convertToDatabaseValue('alice@example.com', $this->platform); + $second = $this->type->convertToDatabaseValue('bob@example.com', $this->platform); + + self::assertNotSame($first, $second); + } + + public function testDifferentKeysProduceDifferentCiphertexts(): void + { + $email = 'user@example.com'; + $other = new EncryptedEmailType(bin2hex(random_bytes(32))); + + $first = $this->type->convertToDatabaseValue($email, $this->platform); + $second = $other->convertToDatabaseValue($email, $this->platform); + + self::assertNotSame($first, $second); + } + + /** @return iterable */ + public static function provideEmails(): iterable + { + yield ['user@example.com']; + yield ['jane_admin@symfony.com']; + yield ['very.long.email.address+tag@subdomain.example.co.uk']; + } + + #[DataProvider('provideEmails')] + public function testRoundtripForVariousEmails(string $email): void + { + $encoded = $this->type->convertToDatabaseValue($email, $this->platform); + $decoded = $this->type->convertToPHPValue($encoded, $this->platform); + + self::assertSame($email, $decoded); + } +}