From d4edc5d000901a2ed85f0910d88084f152d556d2 Mon Sep 17 00:00:00 2001 From: Enzo Innocenzi Date: Thu, 3 Jul 2025 16:10:34 +0200 Subject: [PATCH 01/12] feat: add password hashing --- composer.json | 3 + packages/cryptography/.gitattributes | 14 +++ packages/cryptography/LICENCE.md | 9 ++ packages/cryptography/composer.json | 21 +++++ packages/cryptography/phpunit.xml | 23 +++++ .../cryptography/src/Password/ArgonConfig.php | 27 ++++++ .../src/Password/BcryptConfig.php | 23 +++++ .../src/Password/Exceptions/HashingFailed.php | 18 ++++ .../Exceptions/PasswordHashingException.php | 7 ++ .../src/Password/GenericPasswordHasher.php | 67 ++++++++++++++ packages/cryptography/src/Password/Hash.php | 13 +++ .../src/Password/HashingAlgorithm.php | 16 ++++ .../src/Password/PasswordHasher.php | 33 +++++++ .../Password/PasswordHasherInitializer.php | 16 ++++ .../src/Password/PasswordHashingConfig.php | 17 ++++ .../src/Password/hashing.config.php | 5 ++ .../tests/Password/PasswordHasherTest.php | 90 +++++++++++++++++++ .../Cryptography/PasswordHasherTest.php | 48 ++++++++++ 18 files changed, 450 insertions(+) create mode 100644 packages/cryptography/.gitattributes create mode 100644 packages/cryptography/LICENCE.md create mode 100644 packages/cryptography/composer.json create mode 100644 packages/cryptography/phpunit.xml create mode 100644 packages/cryptography/src/Password/ArgonConfig.php create mode 100644 packages/cryptography/src/Password/BcryptConfig.php create mode 100644 packages/cryptography/src/Password/Exceptions/HashingFailed.php create mode 100644 packages/cryptography/src/Password/Exceptions/PasswordHashingException.php create mode 100644 packages/cryptography/src/Password/GenericPasswordHasher.php create mode 100644 packages/cryptography/src/Password/Hash.php create mode 100644 packages/cryptography/src/Password/HashingAlgorithm.php create mode 100644 packages/cryptography/src/Password/PasswordHasher.php create mode 100644 packages/cryptography/src/Password/PasswordHasherInitializer.php create mode 100644 packages/cryptography/src/Password/PasswordHashingConfig.php create mode 100644 packages/cryptography/src/Password/hashing.config.php create mode 100644 packages/cryptography/tests/Password/PasswordHasherTest.php create mode 100644 tests/Integration/Cryptography/PasswordHasherTest.php diff --git a/composer.json b/composer.json index fb268e264..2d458af14 100644 --- a/composer.json +++ b/composer.json @@ -75,6 +75,7 @@ "tempest/console": "self.version", "tempest/container": "self.version", "tempest/core": "self.version", + "tempest/cryptography": "self.version", "tempest/database": "self.version", "tempest/datetime": "self.version", "tempest/debug": "self.version", @@ -110,6 +111,7 @@ "Tempest\\Console\\": "packages/console/src", "Tempest\\Container\\": "packages/container/src", "Tempest\\Core\\": "packages/core/src", + "Tempest\\Cryptography\\": "packages/cryptography/src", "Tempest\\Database\\": "packages/database/src", "Tempest\\DateTime\\": "packages/datetime/src", "Tempest\\Debug\\": "packages/debug/src", @@ -173,6 +175,7 @@ "Tempest\\Console\\Tests\\": "packages/console/tests", "Tempest\\Container\\Tests\\": "packages/container/tests", "Tempest\\Core\\Tests\\": "packages/core/tests", + "Tempest\\Cryptography\\Tests\\": "packages/cryptography/tests", "Tempest\\Database\\Tests\\": "packages/database/tests", "Tempest\\DateTime\\Tests\\": "packages/datetime/tests", "Tempest\\EventBus\\Tests\\": "packages/event-bus/tests", diff --git a/packages/cryptography/.gitattributes b/packages/cryptography/.gitattributes new file mode 100644 index 000000000..3f7775660 --- /dev/null +++ b/packages/cryptography/.gitattributes @@ -0,0 +1,14 @@ +# Exclude build/test files from the release +.github/ export-ignore +tests/ export-ignore +.gitattributes export-ignore +.gitignore export-ignore +phpunit.xml export-ignore +README.md export-ignore + +# Configure diff output +*.view.php diff=html +*.php diff=php +*.css diff=css +*.html diff=html +*.md diff=markdown diff --git a/packages/cryptography/LICENCE.md b/packages/cryptography/LICENCE.md new file mode 100644 index 000000000..54215b726 --- /dev/null +++ b/packages/cryptography/LICENCE.md @@ -0,0 +1,9 @@ +The MIT License (MIT) + +Copyright (c) 2024 Brent Roose brendt@stitcher.io + +Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. diff --git a/packages/cryptography/composer.json b/packages/cryptography/composer.json new file mode 100644 index 000000000..2d13405b0 --- /dev/null +++ b/packages/cryptography/composer.json @@ -0,0 +1,21 @@ +{ + "name": "tempest/cryptography", + "description": "A component for working with passwords, hashing, and cryptography.", + "license": "MIT", + "minimum-stability": "dev", + "require": { + "php": "^8.4", + "tempest/container": "dev-main", + "tempest/support": "dev-main" + }, + "autoload": { + "psr-4": { + "Tempest\\Cryptography\\": "src" + } + }, + "autoload-dev": { + "psr-4": { + "Tempest\\Cryptography\\Tests\\": "tests" + } + } +} diff --git a/packages/cryptography/phpunit.xml b/packages/cryptography/phpunit.xml new file mode 100644 index 000000000..91bf40caa --- /dev/null +++ b/packages/cryptography/phpunit.xml @@ -0,0 +1,23 @@ + + + + + tests + + + + + src + + + diff --git a/packages/cryptography/src/Password/ArgonConfig.php b/packages/cryptography/src/Password/ArgonConfig.php new file mode 100644 index 000000000..c92ce6558 --- /dev/null +++ b/packages/cryptography/src/Password/ArgonConfig.php @@ -0,0 +1,27 @@ + [ + 'memory_cost' => $this->memoryCost, + 'time_cost' => $this->timeCost, + 'threads' => $this->threads, + ]; + } + + /** + * @param int $memoryCost The amount of memory in bytes that Argon will use while trying to compute a hash. The higher, the more resistant to GPU/ASIC attacks, but also more resource-intensive and slow. + * @param int $timeCost Number of passes Argon will perform over the memory. Increasing this increases the computation time but makes brute-force attacks slower. + * @param int $threads Number of threads used to compute the hash. More threads can improve performance on multi-core CPUs by parallelizing the computation, but may impact other processes. + */ + public function __construct( + public int $memoryCost = PASSWORD_ARGON2_DEFAULT_MEMORY_COST, + public int $timeCost = PASSWORD_ARGON2_DEFAULT_TIME_COST, + public int $threads = PASSWORD_ARGON2_DEFAULT_THREADS, + ) {} +} diff --git a/packages/cryptography/src/Password/BcryptConfig.php b/packages/cryptography/src/Password/BcryptConfig.php new file mode 100644 index 000000000..084c6fedb --- /dev/null +++ b/packages/cryptography/src/Password/BcryptConfig.php @@ -0,0 +1,23 @@ + [ + 'cost' => $this->cost, + ]; + } + + /** + * @param int $cost Number of iterations bcrypt will perform. Increasing this increases the computation time but makes brute-force attacks slower. + */ + public function __construct( + public int $cost = PASSWORD_BCRYPT_DEFAULT_COST, + ) {} +} diff --git a/packages/cryptography/src/Password/Exceptions/HashingFailed.php b/packages/cryptography/src/Password/Exceptions/HashingFailed.php new file mode 100644 index 000000000..fe356eeed --- /dev/null +++ b/packages/cryptography/src/Password/Exceptions/HashingFailed.php @@ -0,0 +1,18 @@ + $this->config->algorithm; + } + + public function __construct( + private readonly PasswordHashingConfig $config, + ) {} + + public function hash(#[\SensitiveParameter] string $password): string + { + $hash = password_hash($password, $this->algorithm->value, $this->config->options); + + if ($hash === false) { + throw HashingFailed::forUnknownReason(); + } + + if (mb_strlen($password) === 0) { + throw HashingFailed::forEmptyPassword(); + } + + return $hash; + } + + public function verify(#[\SensitiveParameter] string $password, #[\SensitiveParameter] string $hash): bool + { + if (mb_strlen($hash) === 0) { + return false; + } + + return password_verify($password, $hash); + } + + public function needsRehash(#[\SensitiveParameter] string $hash): bool + { + return password_needs_rehash($hash, $this->algorithm->value, $this->config->options); + } + + public function analyze(#[\SensitiveParameter] string $hash): Hash + { + $info = password_get_info($hash); + $algorithm = HashingAlgorithm::from($info['algo']); + + return new Hash( + hash: $hash, + algorithm: $algorithm, + config: match ($algorithm) { + HashingAlgorithm::BCRYPT => new BcryptConfig( + cost: $info['options']['cost'] ?? PASSWORD_BCRYPT_DEFAULT_COST, + ), + HashingAlgorithm::ARGON2ID => new ArgonConfig( + memoryCost: $info['options']['memory_cost'] ?? PASSWORD_ARGON2_DEFAULT_MEMORY_COST, + timeCost: $info['options']['time_cost'] ?? PASSWORD_ARGON2_DEFAULT_TIME_COST, + threads: $info['options']['threads'] ?? PASSWORD_ARGON2_DEFAULT_THREADS, + ), + }, + ); + } +} diff --git a/packages/cryptography/src/Password/Hash.php b/packages/cryptography/src/Password/Hash.php new file mode 100644 index 000000000..d1242fb7e --- /dev/null +++ b/packages/cryptography/src/Password/Hash.php @@ -0,0 +1,13 @@ +get(PasswordHashingConfig::class)); + } +} diff --git a/packages/cryptography/src/Password/PasswordHashingConfig.php b/packages/cryptography/src/Password/PasswordHashingConfig.php new file mode 100644 index 000000000..a561ed49b --- /dev/null +++ b/packages/cryptography/src/Password/PasswordHashingConfig.php @@ -0,0 +1,17 @@ +assertSame(HashingAlgorithm::ARGON2ID, $hasher->algorithm); + + $hasher = new GenericPasswordHasher(new BcryptConfig()); + $this->assertSame(HashingAlgorithm::BCRYPT, $hasher->algorithm); + } + + public function test_config_options(): void + { + $this->assertSame( + ['memory_cost' => 1024, 'time_cost' => 2, 'threads' => 2], + new ArgonConfig(memoryCost: 1024, timeCost: 2, threads: 2)->options, + ); + + $this->assertSame( + ['cost' => 10], + (new BcryptConfig(cost: 10))->options, + ); + } + + public function test_hash_verify(): void + { + $hasher = new GenericPasswordHasher(new ArgonConfig()); + $password = 'my_secure_password'; // @mago-expect security/no-literal-password + $hash = $hasher->hash($password); + + $this->assertTrue($hasher->verify($password, $hash)); + } + + public function test_wrong_password(): void + { + $hasher = new GenericPasswordHasher(new ArgonConfig()); + $hash = $hasher->hash('my_secure_password'); + + $this->assertFalse($hasher->verify('wrong_password', $hash)); + } + + public function test_needs_rehash(): void + { + $hasher1 = new GenericPasswordHasher(new ArgonConfig(timeCost: 2)); + $hasher2 = new GenericPasswordHasher(new ArgonConfig(timeCost: 4)); + $hash = $hasher1->hash('my_secure_password'); + + $this->assertFalse($hasher1->needsRehash($hash)); + $this->assertTrue($hasher2->needsRehash($hash)); + } + + public function test_analyze(): void + { + $hasher = new GenericPasswordHasher(new ArgonConfig( + memoryCost: 1024, + timeCost: 2, + threads: 2, + )); + + $hash = $hasher->hash('my_secure_password'); + $analysis = $hasher->analyze($hash); + + $this->assertSame($hash, $analysis->hash); + $this->assertSame(HashingAlgorithm::ARGON2ID, $analysis->algorithm); + $this->assertInstanceOf(ArgonConfig::class, $analysis->config); + $this->assertSame(1024, $analysis->config->memoryCost); + $this->assertSame(2, $analysis->config->timeCost); + $this->assertSame(2, $analysis->config->threads); + } + + public function test_hashing_failed_empty_password(): void + { + $this->expectException(HashingFailed::class); + $this->expectExceptionMessage('Could not hash an empty password.'); + + $hasher = new GenericPasswordHasher(new BcryptConfig()); + $hasher->hash(''); + } +} diff --git a/tests/Integration/Cryptography/PasswordHasherTest.php b/tests/Integration/Cryptography/PasswordHasherTest.php new file mode 100644 index 000000000..75b671e32 --- /dev/null +++ b/tests/Integration/Cryptography/PasswordHasherTest.php @@ -0,0 +1,48 @@ +container->get(PasswordHasher::class); + $this->assertSame(HashingAlgorithm::ARGON2ID, $hasher->algorithm); + } + + public function test_hash_verify(): void + { + $hasher = $this->container->get(PasswordHasher::class); + + $password = 'my_secure_password'; // @mago-expect security/no-literal-password + $hash = $hasher->hash($password); + + $this->assertTrue($hasher->verify($password, $hash)); + } + + public function test_update_config(): void + { + $this->container->config(new BcryptConfig()); + + $hasher = $this->container->get(PasswordHasher::class); + $this->assertSame(HashingAlgorithm::BCRYPT, $hasher->algorithm); + } + + public function needs_rehash(): void + { + $this->container->config(new BcryptConfig(cost: 2)); + $hasher1 = $this->container->get(PasswordHasher::class); + $hash = $hasher1->hash('my_secure_password'); + + $this->container->config(new BcryptConfig(cost: 3)); + $hasher2 = $this->container->get(PasswordHasher::class); + + $this->assertFalse($hasher1->needsRehash($hash)); + $this->assertTrue($hasher2->needsRehash($hash)); + } +} From f00f895dc5589dc6ce5184dc8e75c2078dd61232 Mon Sep 17 00:00:00 2001 From: Enzo Innocenzi Date: Thu, 3 Jul 2025 17:46:08 +0200 Subject: [PATCH 02/12] feat: add hmac signer --- .../Signing/Exceptions/SigningException.php | 7 + .../Exceptions/SigningKeyWasMissing.php | 13 ++ .../src/Signing/GenericSigner.php | 43 ++++++ .../cryptography/src/Signing/Signature.php | 17 +++ packages/cryptography/src/Signing/Signer.php | 20 +++ .../src/Signing/SignerInitializer.php | 16 ++ .../src/Signing/SigningAlgorithm.php | 11 ++ .../src/Signing/SigningConfig.php | 16 ++ .../src/Signing/signing.config.php | 9 ++ .../cryptography/tests/Signing/SignerTest.php | 137 ++++++++++++++++++ tests/Integration/Cryptography/SignerTest.php | 53 +++++++ 11 files changed, 342 insertions(+) create mode 100644 packages/cryptography/src/Signing/Exceptions/SigningException.php create mode 100644 packages/cryptography/src/Signing/Exceptions/SigningKeyWasMissing.php create mode 100644 packages/cryptography/src/Signing/GenericSigner.php create mode 100644 packages/cryptography/src/Signing/Signature.php create mode 100644 packages/cryptography/src/Signing/Signer.php create mode 100644 packages/cryptography/src/Signing/SignerInitializer.php create mode 100644 packages/cryptography/src/Signing/SigningAlgorithm.php create mode 100644 packages/cryptography/src/Signing/SigningConfig.php create mode 100644 packages/cryptography/src/Signing/signing.config.php create mode 100644 packages/cryptography/tests/Signing/SignerTest.php create mode 100644 tests/Integration/Cryptography/SignerTest.php diff --git a/packages/cryptography/src/Signing/Exceptions/SigningException.php b/packages/cryptography/src/Signing/Exceptions/SigningException.php new file mode 100644 index 000000000..48e42aab5 --- /dev/null +++ b/packages/cryptography/src/Signing/Exceptions/SigningException.php @@ -0,0 +1,7 @@ + $this->config->algorithm; + } + + private string $key { + get { + if (trim($this->config->key) === '') { + throw new SigningKeyWasMissing(); + } + + return $this->config->key; + } + } + + public function __construct( + private readonly SigningConfig $config, + ) {} + + public function sign(string $data): Signature + { + return new Signature(hash_hmac( + algo: $this->algorithm->value, + data: $data, + key: $this->key, + )); + } + + public function verify(string $data, Signature $signature): bool + { + return hash_equals( + known_string: $this->sign($data)->signature, + user_string: $signature->signature, + ); + } +} diff --git a/packages/cryptography/src/Signing/Signature.php b/packages/cryptography/src/Signing/Signature.php new file mode 100644 index 000000000..0cbc2759a --- /dev/null +++ b/packages/cryptography/src/Signing/Signature.php @@ -0,0 +1,17 @@ +signature; + } +} diff --git a/packages/cryptography/src/Signing/Signer.php b/packages/cryptography/src/Signing/Signer.php new file mode 100644 index 000000000..6d40b6108 --- /dev/null +++ b/packages/cryptography/src/Signing/Signer.php @@ -0,0 +1,20 @@ +get(SigningConfig::class)); + } +} diff --git a/packages/cryptography/src/Signing/SigningAlgorithm.php b/packages/cryptography/src/Signing/SigningAlgorithm.php new file mode 100644 index 000000000..ae9df5476 --- /dev/null +++ b/packages/cryptography/src/Signing/SigningAlgorithm.php @@ -0,0 +1,11 @@ +sign($data); + + $this->assertTrue($signer->verify($data, $signature)); + } + + public function test_bad_signature(): void + { + $signer = new GenericSigner(new SigningConfig( + algorithm: SigningAlgorithm::SHA256, + key: 'my_secret_key', + )); + + $data = 'important data'; + $signature = $signer->sign($data); + + // Tamper with the data + $tamperedData = 'tampered data'; + + $this->assertFalse($signer->verify($tamperedData, $signature)); + } + + public function test_different_algoritms(): void + { + $signer1 = new GenericSigner(new SigningConfig( + algorithm: SigningAlgorithm::SHA256, + key: 'my_secret_key', + )); + + $signer2 = new GenericSigner(new SigningConfig( + algorithm: SigningAlgorithm::SHA512, + key: 'my_secret_key', + )); + + $data = 'important data'; + $signature1 = $signer1->sign($data); + $signature2 = $signer2->sign($data); + + // Signatures should be different due to different algorithms + $this->assertNotEquals($signature1, $signature2); + + // Verify with the correct signer + $this->assertTrue($signer1->verify($data, $signature1)); + $this->assertTrue($signer2->verify($data, $signature2)); + + // Verify with the wrong signer + $this->assertFalse($signer1->verify($data, $signature2)); + $this->assertFalse($signer2->verify($data, $signature1)); + } + + public function test_no_signing_key(): void + { + $this->expectException(SigningKeyWasMissing::class); + + $signer = new GenericSigner(new SigningConfig( + algorithm: SigningAlgorithm::SHA256, + key: '', + )); + + $signer->sign('important data'); + } + + public function test_empty_data(): void + { + $signer = new GenericSigner(new SigningConfig( + algorithm: SigningAlgorithm::SHA256, + key: 'my_secret_key', + )); + + $signature = $signer->sign(''); + + // An empty string should still produce a valid signature + $this->assertTrue($signer->verify('', $signature)); + } + + public function test_consistent_signature(): void + { + $signer = new GenericSigner(new SigningConfig( + algorithm: SigningAlgorithm::SHA256, + key: 'my_secret_key', + )); + + $data = 'important data'; + $signature1 = $signer->sign($data); + $signature2 = $signer->sign($data); + + // Signing the same data should produce the same signature + $this->assertEquals($signature1, $signature2); + } + + public function test_different_keys(): void + { + $signer1 = new GenericSigner(new SigningConfig( + algorithm: SigningAlgorithm::SHA256, + key: 'signer1_key_foo', + )); + + $signer2 = new GenericSigner(new SigningConfig( + algorithm: SigningAlgorithm::SHA512, + key: 'signer2_key_bar', + )); + + $data = 'important data'; + $signature1 = $signer1->sign($data); + $signature2 = $signer2->sign($data); + + // Signatures should be different due to different keys + $this->assertNotEquals($signature1, $signature2); + + // Verify with the correct signer + $this->assertTrue($signer1->verify($data, $signature1)); + $this->assertTrue($signer2->verify($data, $signature2)); + + // Verify with the wrong signer + $this->assertFalse($signer1->verify($data, $signature2)); + $this->assertFalse($signer2->verify($data, $signature1)); + } +} diff --git a/tests/Integration/Cryptography/SignerTest.php b/tests/Integration/Cryptography/SignerTest.php new file mode 100644 index 000000000..abea9a37d --- /dev/null +++ b/tests/Integration/Cryptography/SignerTest.php @@ -0,0 +1,53 @@ + $this->container->get(Signer::class); + } + + public function test_default_algorithm(): void + { + $this->assertSame(SigningAlgorithm::SHA256, $this->signer->algorithm); + } + + public function test_signature_valid(): void + { + $this->container->config(new SigningConfig( + algorithm: SigningAlgorithm::SHA256, + key: 'my_secret_key', + )); + + $data = 'important data'; + $signature = $this->signer->sign($data); + + $this->assertTrue($this->signer->verify($data, $signature)); + } + + public function test_update_key(): void + { + $this->container->config(new SigningConfig( + algorithm: SigningAlgorithm::SHA256, + key: 'my_secret_key', + )); + + $signature = $this->signer->sign('important data'); + $this->assertTrue($this->signer->verify('important data', $signature)); + + $this->container->config(new SigningConfig( + algorithm: SigningAlgorithm::SHA256, + key: 'my_secret_key2', + )); + + $this->container->unregister(Signer::class); + + $this->assertFalse($this->signer->verify('important data', $signature)); + } +} From a19279cfbbefd1a0a5caf51f3d038716375a27ac Mon Sep 17 00:00:00 2001 From: Enzo Innocenzi Date: Thu, 3 Jul 2025 18:10:02 +0200 Subject: [PATCH 03/12] feat: add timelock --- packages/clock/src/GenericClock.php | 9 +- packages/cryptography/composer.json | 3 + packages/cryptography/src/Timelock.php | 51 ++++++++++ .../cryptography/src/TimelockInitializer.php | 15 +++ packages/cryptography/tests/TimelockTest.php | 94 +++++++++++++++++++ .../Integration/Cryptography/TimelockTest.php | 25 +++++ 6 files changed, 192 insertions(+), 5 deletions(-) create mode 100644 packages/cryptography/src/Timelock.php create mode 100644 packages/cryptography/src/TimelockInitializer.php create mode 100644 packages/cryptography/tests/TimelockTest.php create mode 100644 tests/Integration/Cryptography/TimelockTest.php diff --git a/packages/clock/src/GenericClock.php b/packages/clock/src/GenericClock.php index 62b04cbcd..604c2d276 100644 --- a/packages/clock/src/GenericClock.php +++ b/packages/clock/src/GenericClock.php @@ -41,10 +41,9 @@ public function milliseconds(): int public function sleep(int|Duration $milliseconds): void { - if ($milliseconds instanceof Duration) { - $milliseconds = (int) $milliseconds->getTotalMilliseconds(); - } - - usleep($milliseconds * MILLISECONDS_PER_SECOND); + usleep(match (true) { + is_int($milliseconds) => $milliseconds * MILLISECONDS_PER_SECOND, + $milliseconds instanceof Duration => (int) $milliseconds->getTotalMicroseconds(), + }); } } diff --git a/packages/cryptography/composer.json b/packages/cryptography/composer.json index 2d13405b0..58af54d58 100644 --- a/packages/cryptography/composer.json +++ b/packages/cryptography/composer.json @@ -8,6 +8,9 @@ "tempest/container": "dev-main", "tempest/support": "dev-main" }, + "suggest": { + "tempest/clock": "For time-lock support" + }, "autoload": { "psr-4": { "Tempest\\Cryptography\\": "src" diff --git a/packages/cryptography/src/Timelock.php b/packages/cryptography/src/Timelock.php new file mode 100644 index 000000000..99408c40e --- /dev/null +++ b/packages/cryptography/src/Timelock.php @@ -0,0 +1,51 @@ +getTotalMicroseconds() - ((microtime(true) - $start) * 1_000_000)); + + if (! $this->canReturnEarly && $remainderInMicroseconds > 0) { + $this->clock->sleep(Duration::microseconds($remainderInMicroseconds)); + } + + if ($exception) { + throw $exception; + } + + return $result; + } +} diff --git a/packages/cryptography/src/TimelockInitializer.php b/packages/cryptography/src/TimelockInitializer.php new file mode 100644 index 000000000..47b58d362 --- /dev/null +++ b/packages/cryptography/src/TimelockInitializer.php @@ -0,0 +1,15 @@ +get(Clock::class)); + } +} diff --git a/packages/cryptography/tests/TimelockTest.php b/packages/cryptography/tests/TimelockTest.php new file mode 100644 index 000000000..b73b8d366 --- /dev/null +++ b/packages/cryptography/tests/TimelockTest.php @@ -0,0 +1,94 @@ +markTestSkipped('The Clock interface is not available. This test requires the `tempest/clock` package.'); + } + } + + public function test_callback_is_executed(): void + { + $clock = new GenericClock(); + $result = new Timelock($clock)->invoke(fn () => 'hello', Duration::zero()); + + $this->assertSame('hello', $result); + } + + public function test_locks_for_duration(): void + { + $clock = new GenericClock(); + $start = microtime(true); + + $timelock = new Timelock($clock); + $timelock->invoke(fn () => null, Duration::milliseconds(100)); + + $elapsed = microtime(true) - $start; + + $this->assertGreaterThanOrEqual(0.1, $elapsed, 'The timelock did not wait for the specified duration.'); + $this->assertLessThan(0.2, $elapsed, 'The timelock waited for too long.'); + } + + public function test_return_early(): void + { + $clock = new GenericClock(); + $timelock = new Timelock($clock); + + $start = microtime(true); + $timelock->invoke( + callback: fn (Timelock $lock) => $lock->canReturnEarly = true, + duration: Duration::milliseconds(100), + ); + $elapsed = microtime(true) - $start; + + $this->assertLessThan(0.1, $elapsed, 'The timelock did not return early as expected.'); + } + + public function test_throws_exception_after_delay(): void + { + $clock = new GenericClock(); + $timelock = new Timelock($clock); + + $start = microtime(true); + + try { + $timelock->invoke( + callback: fn () => throw new \RuntimeException('This is an error.'), + duration: Duration::milliseconds(100), + ); + } catch (\RuntimeException) { + $elapsed = microtime(true) - $start; + $this->assertGreaterThanOrEqual(0.1, $elapsed, 'The exception was thrown before the timelock duration elapsed.'); + } + } + + public function test_uses_clock_to_sleep(): void + { + $clock = new MockClock(); + $timelock = new Timelock($clock); + + $ms = $clock->timestamp()->getMilliseconds(); + + $timelock->invoke( + callback: fn () => null, + duration: Duration::milliseconds(300), + ); + + $elapsed = $clock->timestamp()->getMilliseconds() - $ms; + + $this->assertSame(300, $elapsed); + } +} diff --git a/tests/Integration/Cryptography/TimelockTest.php b/tests/Integration/Cryptography/TimelockTest.php new file mode 100644 index 000000000..d514bab1a --- /dev/null +++ b/tests/Integration/Cryptography/TimelockTest.php @@ -0,0 +1,25 @@ +clock(); + $timelock = $this->container->get(Timelock::class); + + $ms = $clock->timestamp()->getMilliseconds(); + $timelock->invoke( + callback: fn () => null, + duration: Duration::milliseconds(10_000), + ); + $elapsed = $clock->timestamp()->getMilliseconds() - $ms; + + $this->assertSame(10_000, $elapsed); + } +} From bf5f34c335b18b84f4939525ccdee03b33a68604 Mon Sep 17 00:00:00 2001 From: Enzo Innocenzi Date: Thu, 3 Jul 2025 18:22:49 +0200 Subject: [PATCH 04/12] feat: protect signing from timing attacks --- packages/cryptography/composer.json | 6 +- .../src/Signing/GenericSigner.php | 12 ++- .../src/Signing/SignerInitializer.php | 6 +- .../src/Signing/SigningConfig.php | 4 + .../src/Signing/signing.config.php | 1 + .../cryptography/tests/Signing/SignerTest.php | 80 ++++++++++++++++--- packages/cryptography/tests/TimelockTest.php | 9 --- tests/Integration/Cryptography/SignerTest.php | 3 + 8 files changed, 95 insertions(+), 26 deletions(-) diff --git a/packages/cryptography/composer.json b/packages/cryptography/composer.json index 58af54d58..40b072222 100644 --- a/packages/cryptography/composer.json +++ b/packages/cryptography/composer.json @@ -6,10 +6,8 @@ "require": { "php": "^8.4", "tempest/container": "dev-main", - "tempest/support": "dev-main" - }, - "suggest": { - "tempest/clock": "For time-lock support" + "tempest/support": "dev-main", + "tempest/clock": "dev-main" }, "autoload": { "psr-4": { diff --git a/packages/cryptography/src/Signing/GenericSigner.php b/packages/cryptography/src/Signing/GenericSigner.php index 475e306af..b79356b17 100644 --- a/packages/cryptography/src/Signing/GenericSigner.php +++ b/packages/cryptography/src/Signing/GenericSigner.php @@ -3,6 +3,8 @@ namespace Tempest\Cryptography\Signing; use Tempest\Cryptography\Signing\Exceptions\SigningKeyWasMissing; +use Tempest\Cryptography\Timelock; +use Tempest\DateTime\Duration; final class GenericSigner implements Signer { @@ -22,6 +24,7 @@ final class GenericSigner implements Signer public function __construct( private readonly SigningConfig $config, + private readonly Timelock $timelock, ) {} public function sign(string $data): Signature @@ -35,9 +38,12 @@ public function sign(string $data): Signature public function verify(string $data, Signature $signature): bool { - return hash_equals( - known_string: $this->sign($data)->signature, - user_string: $signature->signature, + return $this->timelock->invoke( + callback: fn () => hash_equals( + known_string: $this->sign($data)->signature, + user_string: $signature->signature, + ), + duration: $this->config->minimumExecutionDuration ?: Duration::zero(), ); } } diff --git a/packages/cryptography/src/Signing/SignerInitializer.php b/packages/cryptography/src/Signing/SignerInitializer.php index 1040365ca..46430f062 100644 --- a/packages/cryptography/src/Signing/SignerInitializer.php +++ b/packages/cryptography/src/Signing/SignerInitializer.php @@ -5,12 +5,16 @@ use Tempest\Container\Container; use Tempest\Container\Initializer; use Tempest\Container\Singleton; +use Tempest\Cryptography\Timelock; final class SignerInitializer implements Initializer { #[Singleton] public function initialize(Container $container): Signer { - return new GenericSigner($container->get(SigningConfig::class)); + return new GenericSigner( + config: $container->get(SigningConfig::class), + timelock: $container->get(Timelock::class), + ); } } diff --git a/packages/cryptography/src/Signing/SigningConfig.php b/packages/cryptography/src/Signing/SigningConfig.php index a13eda8c8..c52285ee8 100644 --- a/packages/cryptography/src/Signing/SigningConfig.php +++ b/packages/cryptography/src/Signing/SigningConfig.php @@ -2,15 +2,19 @@ namespace Tempest\Cryptography\Signing; +use Tempest\DateTime\Duration; + final class SigningConfig { /** * @param SigningAlgorithm $algorithm The algorithm used for signing and verifying signatures. * @param non-empty-string $key The key used for signing and verifying signatures. + * @param Duration|false $minimumExecutionDuration The minimum execution duration for signing operations, to prevent timing attacks. Set `false` to disable timing attack protection. */ public function __construct( public SigningAlgorithm $algorithm, #[\SensitiveParameter] public string $key, + public false|Duration $minimumExecutionDuration, ) {} } diff --git a/packages/cryptography/src/Signing/signing.config.php b/packages/cryptography/src/Signing/signing.config.php index 705466807..cc9486bca 100644 --- a/packages/cryptography/src/Signing/signing.config.php +++ b/packages/cryptography/src/Signing/signing.config.php @@ -6,4 +6,5 @@ return new SigningConfig( algorithm: SigningAlgorithm::SHA256, key: Tempest\env('SIGNING_KEY', default: ''), + minimumExecutionDuration: false, ); diff --git a/packages/cryptography/tests/Signing/SignerTest.php b/packages/cryptography/tests/Signing/SignerTest.php index 830edc8b3..6cecdd19f 100644 --- a/packages/cryptography/tests/Signing/SignerTest.php +++ b/packages/cryptography/tests/Signing/SignerTest.php @@ -3,18 +3,36 @@ namespace Tempest\Cryptography\Tests\Signing; use PHPUnit\Framework\TestCase; +use Tempest\Clock\Clock; +use Tempest\Clock\GenericClock; +use Tempest\Clock\MockClock; use Tempest\Cryptography\Signing\Exceptions\SigningKeyWasMissing; use Tempest\Cryptography\Signing\GenericSigner; use Tempest\Cryptography\Signing\SigningAlgorithm; use Tempest\Cryptography\Signing\SigningConfig; +use Tempest\Cryptography\Timelock; +use Tempest\DateTime\Duration; final class SignerTest extends TestCase { + private function createSigner(SigningConfig $config, ?Clock $clock = null): GenericSigner + { + return new GenericSigner( + config: $config ?? new SigningConfig( + algorithm: SigningAlgorithm::SHA256, + key: 'my_secret_key', + minimumExecutionDuration: false, + ), + timelock: new Timelock($clock ?? new GenericClock()), + ); + } + public function test_good_signature(): void { - $signer = new GenericSigner(new SigningConfig( + $signer = $this->createSigner(new SigningConfig( algorithm: SigningAlgorithm::SHA256, key: 'my_secret_key', + minimumExecutionDuration: false, )); $data = 'important data'; @@ -25,9 +43,10 @@ public function test_good_signature(): void public function test_bad_signature(): void { - $signer = new GenericSigner(new SigningConfig( + $signer = $this->createSigner(new SigningConfig( algorithm: SigningAlgorithm::SHA256, key: 'my_secret_key', + minimumExecutionDuration: false, )); $data = 'important data'; @@ -41,14 +60,16 @@ public function test_bad_signature(): void public function test_different_algoritms(): void { - $signer1 = new GenericSigner(new SigningConfig( + $signer1 = $this->createSigner(new SigningConfig( algorithm: SigningAlgorithm::SHA256, key: 'my_secret_key', + minimumExecutionDuration: false, )); - $signer2 = new GenericSigner(new SigningConfig( + $signer2 = $this->createSigner(new SigningConfig( algorithm: SigningAlgorithm::SHA512, key: 'my_secret_key', + minimumExecutionDuration: false, )); $data = 'important data'; @@ -71,9 +92,10 @@ public function test_no_signing_key(): void { $this->expectException(SigningKeyWasMissing::class); - $signer = new GenericSigner(new SigningConfig( + $signer = $this->createSigner(new SigningConfig( algorithm: SigningAlgorithm::SHA256, key: '', + minimumExecutionDuration: false, )); $signer->sign('important data'); @@ -81,9 +103,10 @@ public function test_no_signing_key(): void public function test_empty_data(): void { - $signer = new GenericSigner(new SigningConfig( + $signer = $this->createSigner(new SigningConfig( algorithm: SigningAlgorithm::SHA256, key: 'my_secret_key', + minimumExecutionDuration: false, )); $signature = $signer->sign(''); @@ -94,9 +117,10 @@ public function test_empty_data(): void public function test_consistent_signature(): void { - $signer = new GenericSigner(new SigningConfig( + $signer = $this->createSigner(new SigningConfig( algorithm: SigningAlgorithm::SHA256, key: 'my_secret_key', + minimumExecutionDuration: false, )); $data = 'important data'; @@ -109,14 +133,16 @@ public function test_consistent_signature(): void public function test_different_keys(): void { - $signer1 = new GenericSigner(new SigningConfig( + $signer1 = $this->createSigner(new SigningConfig( algorithm: SigningAlgorithm::SHA256, key: 'signer1_key_foo', + minimumExecutionDuration: false, )); - $signer2 = new GenericSigner(new SigningConfig( + $signer2 = $this->createSigner(new SigningConfig( algorithm: SigningAlgorithm::SHA512, key: 'signer2_key_bar', + minimumExecutionDuration: false, )); $data = 'important data'; @@ -134,4 +160,40 @@ public function test_different_keys(): void $this->assertFalse($signer1->verify($data, $signature2)); $this->assertFalse($signer2->verify($data, $signature1)); } + + public function test_time_protection(): void + { + $signer = $this->createSigner(new SigningConfig( + algorithm: SigningAlgorithm::SHA256, + key: 'my_secret_key', + minimumExecutionDuration: Duration::milliseconds(300), + )); + + $data = 'important data'; + $signature = $signer->sign($data); + + $start = microtime(true); + $this->assertTrue($signer->verify($data, $signature)); + $elapsed = microtime(true) - $start; + + $this->assertGreaterThanOrEqual(0.3, $elapsed); + } + + public function test_time_protection_with_mock_clock(): void + { + $signer = $this->createSigner(new SigningConfig( + algorithm: SigningAlgorithm::SHA256, + key: 'my_secret_key', + minimumExecutionDuration: Duration::second(), + ), $clock = new MockClock()); + + $data = 'important data'; + $signature = $signer->sign($data); + + $ms = $clock->timestamp()->getMilliseconds(); + $this->assertTrue($signer->verify($data, $signature)); + $elapsed = $clock->timestamp()->getMilliseconds() - $ms; + + $this->assertSame(1_000, $elapsed); + } } diff --git a/packages/cryptography/tests/TimelockTest.php b/packages/cryptography/tests/TimelockTest.php index b73b8d366..977ae407c 100644 --- a/packages/cryptography/tests/TimelockTest.php +++ b/packages/cryptography/tests/TimelockTest.php @@ -11,15 +11,6 @@ final class TimelockTest extends TestCase { - protected function setUp(): void - { - parent::setUp(); - - if (! interface_exists(Clock::class)) { - $this->markTestSkipped('The Clock interface is not available. This test requires the `tempest/clock` package.'); - } - } - public function test_callback_is_executed(): void { $clock = new GenericClock(); diff --git a/tests/Integration/Cryptography/SignerTest.php b/tests/Integration/Cryptography/SignerTest.php index abea9a37d..e1c79871a 100644 --- a/tests/Integration/Cryptography/SignerTest.php +++ b/tests/Integration/Cryptography/SignerTest.php @@ -23,6 +23,7 @@ public function test_signature_valid(): void $this->container->config(new SigningConfig( algorithm: SigningAlgorithm::SHA256, key: 'my_secret_key', + minimumExecutionDuration: false, )); $data = 'important data'; @@ -36,6 +37,7 @@ public function test_update_key(): void $this->container->config(new SigningConfig( algorithm: SigningAlgorithm::SHA256, key: 'my_secret_key', + minimumExecutionDuration: false, )); $signature = $this->signer->sign('important data'); @@ -44,6 +46,7 @@ public function test_update_key(): void $this->container->config(new SigningConfig( algorithm: SigningAlgorithm::SHA256, key: 'my_secret_key2', + minimumExecutionDuration: false, )); $this->container->unregister(Signer::class); From 441fe926ce3886cd55a6746e42ac40b3434a13b9 Mon Sep 17 00:00:00 2001 From: Enzo Innocenzi Date: Thu, 3 Jul 2025 18:39:34 +0200 Subject: [PATCH 05/12] test: adapt time protection test for windows --- packages/cryptography/tests/Signing/SignerTest.php | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/packages/cryptography/tests/Signing/SignerTest.php b/packages/cryptography/tests/Signing/SignerTest.php index 6cecdd19f..21eeae152 100644 --- a/packages/cryptography/tests/Signing/SignerTest.php +++ b/packages/cryptography/tests/Signing/SignerTest.php @@ -176,7 +176,8 @@ public function test_time_protection(): void $this->assertTrue($signer->verify($data, $signature)); $elapsed = microtime(true) - $start; - $this->assertGreaterThanOrEqual(0.3, $elapsed); + $this->assertGreaterThanOrEqual(0.29, $elapsed); + $this->assertLessThanOrEqual(0.311, $elapsed); } public function test_time_protection_with_mock_clock(): void @@ -194,6 +195,7 @@ public function test_time_protection_with_mock_clock(): void $this->assertTrue($signer->verify($data, $signature)); $elapsed = $clock->timestamp()->getMilliseconds() - $ms; - $this->assertSame(1_000, $elapsed); + $this->assertLessThanOrEqual(1_001, $elapsed); + $this->assertGreaterThanOrEqual(999, $elapsed); } } From 96ae9b99104ff8c53a631fcc1b97176509ceef60 Mon Sep 17 00:00:00 2001 From: Enzo Innocenzi Date: Thu, 3 Jul 2025 21:38:58 +0200 Subject: [PATCH 06/12] refactor: add signing key value object --- .../Exceptions/SigningKeyWasInvalid.php | 13 ++++++++ .../Exceptions/SigningKeyWasMissing.php | 13 -------- .../src/Signing/GenericSigner.php | 16 ++++------ .../cryptography/src/Signing/Signature.php | 9 ++++-- .../cryptography/src/Signing/SigningKey.php | 30 +++++++++++++++++++ .../cryptography/tests/Signing/SignerTest.php | 3 +- 6 files changed, 57 insertions(+), 27 deletions(-) create mode 100644 packages/cryptography/src/Signing/Exceptions/SigningKeyWasInvalid.php delete mode 100644 packages/cryptography/src/Signing/Exceptions/SigningKeyWasMissing.php create mode 100644 packages/cryptography/src/Signing/SigningKey.php diff --git a/packages/cryptography/src/Signing/Exceptions/SigningKeyWasInvalid.php b/packages/cryptography/src/Signing/Exceptions/SigningKeyWasInvalid.php new file mode 100644 index 000000000..45d5dfd95 --- /dev/null +++ b/packages/cryptography/src/Signing/Exceptions/SigningKeyWasInvalid.php @@ -0,0 +1,13 @@ + $this->config->algorithm; } - private string $key { - get { - if (trim($this->config->key) === '') { - throw new SigningKeyWasMissing(); - } - - return $this->config->key; - } + private SigningKey $key { + get => SigningKey::fromString($this->config->key); } public function __construct( @@ -32,7 +26,7 @@ public function sign(string $data): Signature return new Signature(hash_hmac( algo: $this->algorithm->value, data: $data, - key: $this->key, + key: $this->key->value, )); } @@ -40,8 +34,8 @@ public function verify(string $data, Signature $signature): bool { return $this->timelock->invoke( callback: fn () => hash_equals( - known_string: $this->sign($data)->signature, - user_string: $signature->signature, + known_string: $this->sign($data)->value, + user_string: $signature->value, ), duration: $this->config->minimumExecutionDuration ?: Duration::zero(), ); diff --git a/packages/cryptography/src/Signing/Signature.php b/packages/cryptography/src/Signing/Signature.php index 0cbc2759a..a1af3fd7c 100644 --- a/packages/cryptography/src/Signing/Signature.php +++ b/packages/cryptography/src/Signing/Signature.php @@ -7,11 +7,16 @@ final readonly class Signature implements Stringable { public function __construct( - public string $signature, + public string $value, ) {} public function __toString(): string { - return $this->signature; + return $this->value; + } + + public static function from(string $value): self + { + return new self($value); } } diff --git a/packages/cryptography/src/Signing/SigningKey.php b/packages/cryptography/src/Signing/SigningKey.php new file mode 100644 index 000000000..bcc59ef3e --- /dev/null +++ b/packages/cryptography/src/Signing/SigningKey.php @@ -0,0 +1,30 @@ +value; + } +} diff --git a/packages/cryptography/tests/Signing/SignerTest.php b/packages/cryptography/tests/Signing/SignerTest.php index 21eeae152..d580442ca 100644 --- a/packages/cryptography/tests/Signing/SignerTest.php +++ b/packages/cryptography/tests/Signing/SignerTest.php @@ -6,6 +6,7 @@ use Tempest\Clock\Clock; use Tempest\Clock\GenericClock; use Tempest\Clock\MockClock; +use Tempest\Cryptography\Signing\Exceptions\SigningKeyWasInvalid; use Tempest\Cryptography\Signing\Exceptions\SigningKeyWasMissing; use Tempest\Cryptography\Signing\GenericSigner; use Tempest\Cryptography\Signing\SigningAlgorithm; @@ -90,7 +91,7 @@ public function test_different_algoritms(): void public function test_no_signing_key(): void { - $this->expectException(SigningKeyWasMissing::class); + $this->expectException(SigningKeyWasInvalid::class); $signer = $this->createSigner(new SigningConfig( algorithm: SigningAlgorithm::SHA256, From b1bf38269ddd7990403a31c90494ae807ca64162 Mon Sep 17 00:00:00 2001 From: Enzo Innocenzi Date: Thu, 3 Jul 2025 21:59:19 +0200 Subject: [PATCH 07/12] feat: add encryption --- .../src/Encryption/EncryptedData.php | 54 ++++++++ .../cryptography/src/Encryption/Encrypter.php | 20 +++ .../src/Encryption/EncrypterInitializer.php | 20 +++ .../src/Encryption/EncryptionAlgorithm.php | 30 +++++ .../src/Encryption/EncryptionConfig.php | 16 +++ .../src/Encryption/EncryptionKey.php | 43 +++++++ .../Exceptions/AlgorithmMismatched.php | 13 ++ .../Exceptions/DecryptionFailed.php | 26 ++++ .../Exceptions/EncryptedDataWasInvalid.php | 13 ++ .../Exceptions/EncryptionException.php | 7 ++ .../Exceptions/EncryptionFailed.php | 26 ++++ .../Exceptions/EncryptionKeyWasInvalid.php | 29 +++++ .../Exceptions/SignatureMismatched.php | 13 ++ .../src/Encryption/GenericEncrypter.php | 116 ++++++++++++++++++ .../src/Encryption/encryption.config.php | 9 ++ packages/cryptography/tests/CreatesSigner.php | 25 ++++ .../tests/Encryption/EncryptionTest.php | 87 +++++++++++++ .../cryptography/tests/Signing/SignerTest.php | 18 +-- .../Cryptography/EncrypterTest.php | 42 +++++++ 19 files changed, 591 insertions(+), 16 deletions(-) create mode 100644 packages/cryptography/src/Encryption/EncryptedData.php create mode 100644 packages/cryptography/src/Encryption/Encrypter.php create mode 100644 packages/cryptography/src/Encryption/EncrypterInitializer.php create mode 100644 packages/cryptography/src/Encryption/EncryptionAlgorithm.php create mode 100644 packages/cryptography/src/Encryption/EncryptionConfig.php create mode 100644 packages/cryptography/src/Encryption/EncryptionKey.php create mode 100644 packages/cryptography/src/Encryption/Exceptions/AlgorithmMismatched.php create mode 100644 packages/cryptography/src/Encryption/Exceptions/DecryptionFailed.php create mode 100644 packages/cryptography/src/Encryption/Exceptions/EncryptedDataWasInvalid.php create mode 100644 packages/cryptography/src/Encryption/Exceptions/EncryptionException.php create mode 100644 packages/cryptography/src/Encryption/Exceptions/EncryptionFailed.php create mode 100644 packages/cryptography/src/Encryption/Exceptions/EncryptionKeyWasInvalid.php create mode 100644 packages/cryptography/src/Encryption/Exceptions/SignatureMismatched.php create mode 100644 packages/cryptography/src/Encryption/GenericEncrypter.php create mode 100644 packages/cryptography/src/Encryption/encryption.config.php create mode 100644 packages/cryptography/tests/CreatesSigner.php create mode 100644 packages/cryptography/tests/Encryption/EncryptionTest.php create mode 100644 tests/Integration/Cryptography/EncrypterTest.php diff --git a/packages/cryptography/src/Encryption/EncryptedData.php b/packages/cryptography/src/Encryption/EncryptedData.php new file mode 100644 index 000000000..cb4e29d71 --- /dev/null +++ b/packages/cryptography/src/Encryption/EncryptedData.php @@ -0,0 +1,54 @@ + base64_encode($this->payload), + 'iv' => base64_encode($this->iv), + 'tag' => base64_encode($this->tag), + 'signature' => $this->signature->value, + 'algorithm' => $this->algorithm->value, + ]; + + return base64_encode(Json\encode($data)); + } + + public static function unserialize(string $data): self + { + $decoded = Json\decode(base64_decode($data)); + + if (! is_array($decoded) || ! isset($decoded['payload'], $decoded['iv'], $decoded['tag'], $decoded['signature'], $decoded['algorithm'])) { + throw EncryptedDataWasInvalid::dueToInvalidFormat(); + } + + return new self( + payload: base64_decode($decoded['payload']), + iv: base64_decode($decoded['iv']), + tag: base64_decode($decoded['tag']), + signature: new Signature($decoded['signature']), + algorithm: EncryptionAlgorithm::from($decoded['algorithm']), + ); + } + + public function __toString(): string + { + return $this->serialize(); + } +} diff --git a/packages/cryptography/src/Encryption/Encrypter.php b/packages/cryptography/src/Encryption/Encrypter.php new file mode 100644 index 000000000..20f3087e5 --- /dev/null +++ b/packages/cryptography/src/Encryption/Encrypter.php @@ -0,0 +1,20 @@ +get(Signer::class), + config: $container->get(EncryptionConfig::class), + ); + } +} diff --git a/packages/cryptography/src/Encryption/EncryptionAlgorithm.php b/packages/cryptography/src/Encryption/EncryptionAlgorithm.php new file mode 100644 index 000000000..a788c8eca --- /dev/null +++ b/packages/cryptography/src/Encryption/EncryptionAlgorithm.php @@ -0,0 +1,30 @@ +value); + } + + public function getIvLength(): int + { + return openssl_cipher_iv_length($this->value); + } + + public function isAead(): bool + { + return match ($this) { + self::AES_256_GCM, self::AES_128_GCM, self::CHACHA20_POLY1305 => true, + self::AES_256_CBC, self::AES_128_CBC => false, + }; + } +} diff --git a/packages/cryptography/src/Encryption/EncryptionConfig.php b/packages/cryptography/src/Encryption/EncryptionConfig.php new file mode 100644 index 000000000..e2ce188a3 --- /dev/null +++ b/packages/cryptography/src/Encryption/EncryptionConfig.php @@ -0,0 +1,16 @@ +getKeyLength()) { + throw EncryptionKeyWasInvalid::becauseLengthMismatched($algorithm); + } + } + + /** + * Generates a new cryptographically secure key using the specified algorithm. + */ + public static function generate(EncryptionAlgorithm $algorithm): self + { + return new self(random_bytes($algorithm->getKeyLength()), $algorithm); + } + + /** + * Creates an encryption key from a string. + */ + public static function fromString(string $key, EncryptionAlgorithm $algorithm): self + { + return new self($key, $algorithm); + } + + public function __toString(): string + { + return $this->value; + } +} diff --git a/packages/cryptography/src/Encryption/Exceptions/AlgorithmMismatched.php b/packages/cryptography/src/Encryption/Exceptions/AlgorithmMismatched.php new file mode 100644 index 000000000..21351a6e7 --- /dev/null +++ b/packages/cryptography/src/Encryption/Exceptions/AlgorithmMismatched.php @@ -0,0 +1,13 @@ + $error]); + } + + public function context(): array + { + return $this->context; + } +} diff --git a/packages/cryptography/src/Encryption/Exceptions/EncryptedDataWasInvalid.php b/packages/cryptography/src/Encryption/Exceptions/EncryptedDataWasInvalid.php new file mode 100644 index 000000000..65db1d112 --- /dev/null +++ b/packages/cryptography/src/Encryption/Exceptions/EncryptedDataWasInvalid.php @@ -0,0 +1,13 @@ + $error]); + } + + public function context(): array + { + return $this->context; + } +} diff --git a/packages/cryptography/src/Encryption/Exceptions/EncryptionKeyWasInvalid.php b/packages/cryptography/src/Encryption/Exceptions/EncryptionKeyWasInvalid.php new file mode 100644 index 000000000..317201ff2 --- /dev/null +++ b/packages/cryptography/src/Encryption/Exceptions/EncryptionKeyWasInvalid.php @@ -0,0 +1,29 @@ +getKeyLength()}).", + $algorithm, + ); + } +} diff --git a/packages/cryptography/src/Encryption/Exceptions/SignatureMismatched.php b/packages/cryptography/src/Encryption/Exceptions/SignatureMismatched.php new file mode 100644 index 000000000..6bc7c2154 --- /dev/null +++ b/packages/cryptography/src/Encryption/Exceptions/SignatureMismatched.php @@ -0,0 +1,13 @@ + $this->config->algorithm; + } + + public EncryptionKey $key { + get => EncryptionKey::fromString($this->config->key, $this->algorithm); + } + + public function __construct( + private readonly Signer $signer, + private readonly EncryptionConfig $config, + ) {} + + public function encrypt(#[\SensitiveParameter] string $data): EncryptedData + { + $iv = random_bytes($this->algorithm->getIvLength()); + $tag = ''; + + if ($this->algorithm->isAead()) { + $payload = openssl_encrypt( + data: $data, + cipher_algo: $this->algorithm->value, + passphrase: $this->key->value, + options: OPENSSL_RAW_DATA, + iv: $iv, + tag: $tag, + ); + } else { + $payload = openssl_encrypt( + data: $data, + cipher_algo: $this->algorithm->value, + passphrase: $this->key->value, + options: OPENSSL_RAW_DATA, + iv: $iv, + ); + } + + if ($payload === false) { + throw EncryptionFailed::becauseOpenSslFailed(openssl_error_string()); + } + + $signature = $this->signer->sign(implode('.', array_filter([ + $this->algorithm->value, + base64_encode($iv), + base64_encode($payload), + $this->algorithm->isAead() ? base64_encode($tag) : null, + ]))); + + return new EncryptedData( + payload: $payload, + iv: $iv, + tag: $tag, + signature: $signature, + algorithm: $this->algorithm, + ); + } + + public function decrypt(string|EncryptedData $data): string + { + if (is_string($data)) { + $data = EncryptedData::unserialize($data); + } + + $signature = implode('.', array_filter([ + $data->algorithm->value, + base64_encode($data->iv), + base64_encode($data->payload), + $data->algorithm->isAead() ? base64_encode($data->tag) : null, + ])); + + if (! $this->signer->verify($signature, $data->signature)) { + throw SignatureMismatched::raise(); + } + + if ($data->algorithm !== $this->algorithm) { + throw AlgorithmMismatched::betweenKeyAndData(); + } + + if ($data->algorithm->isAead()) { + $decrypted = openssl_decrypt( + data: $data->payload, + cipher_algo: $data->algorithm->value, + passphrase: $this->key->value, + options: OPENSSL_RAW_DATA, + iv: $data->iv, + tag: $data->tag, + ); + } else { + $decrypted = openssl_decrypt( + data: $data->payload, + cipher_algo: $data->algorithm->value, + passphrase: $this->key->value, + options: OPENSSL_RAW_DATA, + iv: $data->iv, + ); + } + + if ($decrypted === false) { + throw DecryptionFailed::becauseOpenSslFailed(openssl_error_string()); + } + + return $decrypted; + } +} diff --git a/packages/cryptography/src/Encryption/encryption.config.php b/packages/cryptography/src/Encryption/encryption.config.php new file mode 100644 index 000000000..a3b4332b7 --- /dev/null +++ b/packages/cryptography/src/Encryption/encryption.config.php @@ -0,0 +1,9 @@ +value; + + return new GenericEncrypter( + signer: $this->createSigner(new SigningConfig( + algorithm: SigningAlgorithm::SHA256, + key: $key, + minimumExecutionDuration: $minimumExecutionDuration, + )), + config: new EncryptionConfig( + algorithm: EncryptionAlgorithm::AES_256_GCM, + key: $key, + ), + ); + } + + #[TestWith([''])] + #[TestWith(['sensitive data'])] + #[TestWith(['{"foo":"bar"}'])] + public function test_encrypt(string $data): void + { + $encrypter = $this->createEncrypter(); + $encrypted = $encrypter->encrypt($data); + + $serialized = $encrypted->serialize(); + + $this->assertTrue(json_validate(base64_decode($serialized))); + $this->assertSame($data, $encrypter->decrypt($serialized)); + } + + public function test_time_protection(): void + { + $encrypter = $this->createEncrypter(minimumExecutionDuration: Duration::milliseconds(300)); + + $encrypted = $encrypter->encrypt('important data'); + + $start = microtime(true); + $this->assertSame('important data', $encrypter->decrypt($encrypted)); + $elapsed = microtime(true) - $start; + + $this->assertGreaterThanOrEqual(0.29, $elapsed); + $this->assertLessThanOrEqual(0.311, $elapsed); + } + + public function test_wrong_key(): void + { + $this->expectException(SignatureMismatched::class); + + $encrypter = $this->createEncrypter(EncryptionKey::generate(EncryptionAlgorithm::AES_256_GCM)); + $encrypted = $encrypter->encrypt('sensitive data'); + + $wrongEncrypter = $this->createEncrypter(EncryptionKey::generate(EncryptionAlgorithm::AES_256_GCM)); + $wrongEncrypter->decrypt($encrypted->serialize()); + } + + public function test_missing_key(): void + { + $this->expectException(EncryptionKeyWasInvalid::class); + + $encrypter = $this->createEncrypter(key: ''); + $encrypter->encrypt('sensitive data'); + } +} diff --git a/packages/cryptography/tests/Signing/SignerTest.php b/packages/cryptography/tests/Signing/SignerTest.php index d580442ca..d85d4713a 100644 --- a/packages/cryptography/tests/Signing/SignerTest.php +++ b/packages/cryptography/tests/Signing/SignerTest.php @@ -3,30 +3,16 @@ namespace Tempest\Cryptography\Tests\Signing; use PHPUnit\Framework\TestCase; -use Tempest\Clock\Clock; -use Tempest\Clock\GenericClock; use Tempest\Clock\MockClock; use Tempest\Cryptography\Signing\Exceptions\SigningKeyWasInvalid; -use Tempest\Cryptography\Signing\Exceptions\SigningKeyWasMissing; -use Tempest\Cryptography\Signing\GenericSigner; use Tempest\Cryptography\Signing\SigningAlgorithm; use Tempest\Cryptography\Signing\SigningConfig; -use Tempest\Cryptography\Timelock; +use Tempest\Cryptography\Tests\CreatesSigner; use Tempest\DateTime\Duration; final class SignerTest extends TestCase { - private function createSigner(SigningConfig $config, ?Clock $clock = null): GenericSigner - { - return new GenericSigner( - config: $config ?? new SigningConfig( - algorithm: SigningAlgorithm::SHA256, - key: 'my_secret_key', - minimumExecutionDuration: false, - ), - timelock: new Timelock($clock ?? new GenericClock()), - ); - } + use CreatesSigner; public function test_good_signature(): void { diff --git a/tests/Integration/Cryptography/EncrypterTest.php b/tests/Integration/Cryptography/EncrypterTest.php new file mode 100644 index 000000000..4c986f37f --- /dev/null +++ b/tests/Integration/Cryptography/EncrypterTest.php @@ -0,0 +1,42 @@ + $this->container->get(Encrypter::class); + } + + public function test_default_algorithm(): void + { + $this->assertSame(EncryptionAlgorithm::AES_256_GCM, $this->encrypter->algorithm); + } + + public function test_using_config(): void + { + $this->container->config(new EncryptionConfig( + algorithm: EncryptionAlgorithm::AES_256_GCM, + key: $key = EncryptionKey::generate(EncryptionAlgorithm::AES_256_GCM), + )); + + $this->container->config(new SigningConfig( + algorithm: SigningAlgorithm::SHA256, + key: $key, + minimumExecutionDuration: false, + )); + + $encrypted = $this->encrypter->encrypt('important data'); + + $this->assertSame('important data', $this->encrypter->decrypt($encrypted)); + } +} From 7c47ab9b904fb458c12d68c6dbc0307ef828d267 Mon Sep 17 00:00:00 2001 From: Enzo Innocenzi Date: Thu, 3 Jul 2025 22:34:42 +0200 Subject: [PATCH 08/12] feat: add command to create signing key --- .../src/CreateSigningKeyCommand.php | 63 +++++++++++++++++ .../src/Encryption/EncryptedData.php | 8 +-- .../src/Encryption/EncryptionKey.php | 9 ++- .../tests/Encryption/EncryptionTest.php | 4 +- .../CreateSigningKeyCommandTest.php | 67 +++++++++++++++++++ .../Cryptography/EncrypterTest.php | 2 +- 6 files changed, 144 insertions(+), 9 deletions(-) create mode 100644 packages/cryptography/src/CreateSigningKeyCommand.php create mode 100644 tests/Integration/Cryptography/CreateSigningKeyCommandTest.php diff --git a/packages/cryptography/src/CreateSigningKeyCommand.php b/packages/cryptography/src/CreateSigningKeyCommand.php new file mode 100644 index 000000000..c27fbeb78 --- /dev/null +++ b/packages/cryptography/src/CreateSigningKeyCommand.php @@ -0,0 +1,63 @@ +encryptionConfig->algorithm); + + $this->console->writeln(); + $this->console->success('Signing key generated successfully.'); + + $this->createDotEnvIfNotExists(); + $this->addToDotEnv($key->toString()); + + return ExitCode::SUCCESS; + } + + private function getDotEnvPath(): string + { + return root_path('.env'); + } + + private function addToDotEnv(string $key): void + { + $file = Filesystem\read_file($this->getDotEnvPath()); + + if (! Str\contains($file, 'SIGNING_KEY=')) { + $file .= "\nSIGNING_KEY={$key}\n"; + } else { + $file = Regex\replace($file, '/^SIGNING_KEY=.*$/m', "SIGNING_KEY={$key}"); + } + + Filesystem\write_file($this->getDotEnvPath(), $file); + } + + private function createDotEnvIfNotExists(): void + { + if (Filesystem\exists($this->getDotEnvPath())) { + return; + } + + Filesystem\create_file($this->getDotEnvPath()); + } +} diff --git a/packages/cryptography/src/Encryption/EncryptedData.php b/packages/cryptography/src/Encryption/EncryptedData.php index cb4e29d71..724cbdbfc 100644 --- a/packages/cryptography/src/Encryption/EncryptedData.php +++ b/packages/cryptography/src/Encryption/EncryptedData.php @@ -32,16 +32,16 @@ public function serialize(): string public static function unserialize(string $data): self { - $decoded = Json\decode(base64_decode($data)); + $decoded = Json\decode(base64_decode($data, strict: true)); if (! is_array($decoded) || ! isset($decoded['payload'], $decoded['iv'], $decoded['tag'], $decoded['signature'], $decoded['algorithm'])) { throw EncryptedDataWasInvalid::dueToInvalidFormat(); } return new self( - payload: base64_decode($decoded['payload']), - iv: base64_decode($decoded['iv']), - tag: base64_decode($decoded['tag']), + payload: base64_decode($decoded['payload'], strict: true), + iv: base64_decode($decoded['iv'], strict: true), + tag: base64_decode($decoded['tag'], strict: true), signature: new Signature($decoded['signature']), algorithm: EncryptionAlgorithm::from($decoded['algorithm']), ); diff --git a/packages/cryptography/src/Encryption/EncryptionKey.php b/packages/cryptography/src/Encryption/EncryptionKey.php index 767629ae4..eb06082f3 100644 --- a/packages/cryptography/src/Encryption/EncryptionKey.php +++ b/packages/cryptography/src/Encryption/EncryptionKey.php @@ -33,11 +33,16 @@ public static function generate(EncryptionAlgorithm $algorithm): self */ public static function fromString(string $key, EncryptionAlgorithm $algorithm): self { - return new self($key, $algorithm); + return new self(base64_decode($key, strict: true), $algorithm); + } + + public function toString(): string + { + return base64_encode($this->value); } public function __toString(): string { - return $this->value; + return $this->toString(); } } diff --git a/packages/cryptography/tests/Encryption/EncryptionTest.php b/packages/cryptography/tests/Encryption/EncryptionTest.php index 4f5734aea..4c8375b7a 100644 --- a/packages/cryptography/tests/Encryption/EncryptionTest.php +++ b/packages/cryptography/tests/Encryption/EncryptionTest.php @@ -23,7 +23,7 @@ final class EncryptionTest extends TestCase private function createEncrypter(?string $key = null, false|Duration $minimumExecutionDuration = false): GenericEncrypter { - $key ??= EncryptionKey::generate(EncryptionAlgorithm::AES_256_GCM)->value; + $key ??= EncryptionKey::generate(EncryptionAlgorithm::AES_256_GCM)->toString(); return new GenericEncrypter( signer: $this->createSigner(new SigningConfig( @@ -48,7 +48,7 @@ public function test_encrypt(string $data): void $serialized = $encrypted->serialize(); - $this->assertTrue(json_validate(base64_decode($serialized))); + $this->assertTrue(json_validate(base64_decode($serialized, strict: true))); $this->assertSame($data, $encrypter->decrypt($serialized)); } diff --git a/tests/Integration/Cryptography/CreateSigningKeyCommandTest.php b/tests/Integration/Cryptography/CreateSigningKeyCommandTest.php new file mode 100644 index 000000000..3504edde1 --- /dev/null +++ b/tests/Integration/Cryptography/CreateSigningKeyCommandTest.php @@ -0,0 +1,67 @@ +container->get(FrameworkKernel::class)->root = __DIR__; + } + + protected function tearDown(): void + { + parent::tearDown(); + + Filesystem\delete_file(root_path('.env')); + } + + public function test_creates_dot_env(): void + { + $this->assertFalse(Filesystem\is_file(root_path('.env'))); + $this->console->call(CreateSigningKeyCommand::class)->assertSuccess(); + $this->assertTrue(Filesystem\is_file(root_path('.env'))); + + $file = Filesystem\read_file(root_path('.env')); + $env = Dotenv::createImmutable(__DIR__)->parse($file); + + $this->assertArrayHasKey('SIGNING_KEY', $env); + $this->assertIsString($env['SIGNING_KEY']); + } + + public function test_updates_existing(): void + { + Filesystem\write_file(root_path('.env'), 'SIGNING_KEY=abc'); + $this->console->call(CreateSigningKeyCommand::class)->assertSuccess(); + $this->assertTrue(Filesystem\is_file(root_path('.env'))); + + $file = Filesystem\read_file(root_path('.env')); + $env = Dotenv::createImmutable(__DIR__)->parse($file); + + $this->assertArrayHasKey('SIGNING_KEY', $env); + $this->assertNotSame('abc', $env['SIGNING_KEY']); + } + + public function test_add_if_missing(): void + { + Filesystem\create_file(root_path('.env')); + $this->console->call(CreateSigningKeyCommand::class)->assertSuccess(); + $this->assertTrue(Filesystem\is_file(root_path('.env'))); + + $file = Filesystem\read_file(root_path('.env')); + $env = Dotenv::createImmutable(__DIR__)->parse($file); + + $this->assertArrayHasKey('SIGNING_KEY', $env); + $this->assertIsString($env['SIGNING_KEY']); + } +} diff --git a/tests/Integration/Cryptography/EncrypterTest.php b/tests/Integration/Cryptography/EncrypterTest.php index 4c986f37f..ae870763f 100644 --- a/tests/Integration/Cryptography/EncrypterTest.php +++ b/tests/Integration/Cryptography/EncrypterTest.php @@ -26,7 +26,7 @@ public function test_using_config(): void { $this->container->config(new EncryptionConfig( algorithm: EncryptionAlgorithm::AES_256_GCM, - key: $key = EncryptionKey::generate(EncryptionAlgorithm::AES_256_GCM), + key: $key = EncryptionKey::generate(EncryptionAlgorithm::AES_256_GCM)->toString(), )); $this->container->config(new SigningConfig( From c2d3b37e0226ca9b6ef6ac9c6997f166402941a8 Mon Sep 17 00:00:00 2001 From: Enzo Innocenzi Date: Tue, 29 Jul 2025 11:21:00 +0200 Subject: [PATCH 09/12] refactor: do not use empty encryption key by default --- .../src/Encryption/EncryptionConfig.php | 2 +- .../src/Encryption/EncryptionKey.php | 4 +-- .../src/Encryption/encryption.config.php | 2 +- .../tests/Encryption/EncryptionKeyTest.php | 34 +++++++++++++++++++ .../tests/Encryption/EncryptionTest.php | 2 -- 5 files changed, 38 insertions(+), 6 deletions(-) create mode 100644 packages/cryptography/tests/Encryption/EncryptionKeyTest.php diff --git a/packages/cryptography/src/Encryption/EncryptionConfig.php b/packages/cryptography/src/Encryption/EncryptionConfig.php index e2ce188a3..426c47f76 100644 --- a/packages/cryptography/src/Encryption/EncryptionConfig.php +++ b/packages/cryptography/src/Encryption/EncryptionConfig.php @@ -11,6 +11,6 @@ final class EncryptionConfig public function __construct( public EncryptionAlgorithm $algorithm, #[\SensitiveParameter] - public string $key, + public ?string $key, ) {} } diff --git a/packages/cryptography/src/Encryption/EncryptionKey.php b/packages/cryptography/src/Encryption/EncryptionKey.php index eb06082f3..3e83e99b4 100644 --- a/packages/cryptography/src/Encryption/EncryptionKey.php +++ b/packages/cryptography/src/Encryption/EncryptionKey.php @@ -31,9 +31,9 @@ public static function generate(EncryptionAlgorithm $algorithm): self /** * Creates an encryption key from a string. */ - public static function fromString(string $key, EncryptionAlgorithm $algorithm): self + public static function fromString(?string $key, EncryptionAlgorithm $algorithm): self { - return new self(base64_decode($key, strict: true), $algorithm); + return new self(base64_decode($key ?: '', strict: true), $algorithm); } public function toString(): string diff --git a/packages/cryptography/src/Encryption/encryption.config.php b/packages/cryptography/src/Encryption/encryption.config.php index a3b4332b7..823a571f1 100644 --- a/packages/cryptography/src/Encryption/encryption.config.php +++ b/packages/cryptography/src/Encryption/encryption.config.php @@ -5,5 +5,5 @@ return new EncryptionConfig( algorithm: EncryptionAlgorithm::AES_256_GCM, - key: Tempest\env('SIGNING_KEY', default: ''), + key: Tempest\env('SIGNING_KEY'), ); diff --git a/packages/cryptography/tests/Encryption/EncryptionKeyTest.php b/packages/cryptography/tests/Encryption/EncryptionKeyTest.php new file mode 100644 index 000000000..f92c9bf4b --- /dev/null +++ b/packages/cryptography/tests/Encryption/EncryptionKeyTest.php @@ -0,0 +1,34 @@ +assertNotNull($key->value); + $this->assertTrue(strlen($key->value) === $key->algorithm->getKeyLength()); + $this->assertSame(EncryptionAlgorithm::AES_256_GCM, $key->algorithm); + } + + public function test_encryption_key_with_null(): void + { + $this->expectException(EncryptionKeyWasInvalid::class); + + EncryptionKey::fromString(null, EncryptionAlgorithm::AES_256_GCM); + } + + public function test_encryption_key_with_empty_string(): void + { + $this->expectException(EncryptionKeyWasInvalid::class); + + EncryptionKey::fromString('', EncryptionAlgorithm::AES_256_GCM); + } +} diff --git a/packages/cryptography/tests/Encryption/EncryptionTest.php b/packages/cryptography/tests/Encryption/EncryptionTest.php index 4c8375b7a..8321df764 100644 --- a/packages/cryptography/tests/Encryption/EncryptionTest.php +++ b/packages/cryptography/tests/Encryption/EncryptionTest.php @@ -4,11 +4,9 @@ use PHPUnit\Framework\Attributes\TestWith; use PHPUnit\Framework\TestCase; -use Tempest\Cryptography\Encryption\EncryptedData; use Tempest\Cryptography\Encryption\EncryptionAlgorithm; use Tempest\Cryptography\Encryption\EncryptionConfig; use Tempest\Cryptography\Encryption\EncryptionKey; -use Tempest\Cryptography\Encryption\Exceptions\DecryptionFailed; use Tempest\Cryptography\Encryption\Exceptions\EncryptionKeyWasInvalid; use Tempest\Cryptography\Encryption\Exceptions\SignatureMismatched; use Tempest\Cryptography\Encryption\GenericEncrypter; From bb854feeafd788d851a550c96831e66a272f5c40 Mon Sep 17 00:00:00 2001 From: Enzo Innocenzi Date: Tue, 29 Jul 2025 11:21:06 +0200 Subject: [PATCH 10/12] fix: rename license file --- packages/cryptography/{LICENCE.md => LICENSE.md} | 0 1 file changed, 0 insertions(+), 0 deletions(-) rename packages/cryptography/{LICENCE.md => LICENSE.md} (100%) diff --git a/packages/cryptography/LICENCE.md b/packages/cryptography/LICENSE.md similarity index 100% rename from packages/cryptography/LICENCE.md rename to packages/cryptography/LICENSE.md From 1359588f6fca5dff6f30fcab444bd46d3c56cdc9 Mon Sep 17 00:00:00 2001 From: Enzo Innocenzi Date: Tue, 29 Jul 2025 11:24:01 +0200 Subject: [PATCH 11/12] chore: add docblocks to encryption algorithm methods --- .../src/Encryption/EncryptionAlgorithm.php | 11 +++++++++++ 1 file changed, 11 insertions(+) diff --git a/packages/cryptography/src/Encryption/EncryptionAlgorithm.php b/packages/cryptography/src/Encryption/EncryptionAlgorithm.php index a788c8eca..6519a0a40 100644 --- a/packages/cryptography/src/Encryption/EncryptionAlgorithm.php +++ b/packages/cryptography/src/Encryption/EncryptionAlgorithm.php @@ -10,16 +10,27 @@ enum EncryptionAlgorithm: string case AES_128_CBC = 'aes-128-cbc'; case CHACHA20_POLY1305 = 'chacha20-poly1305'; + /** + * Returns the length of the key, in bytes, for the encryption algorithm. + */ public function getKeyLength(): int { return openssl_cipher_key_length($this->value); } + /** + * Returns the initialization vector (IV) length for the encryption algorithm. + */ public function getIvLength(): int { return openssl_cipher_iv_length($this->value); } + /** + * Determines if the encryption algorithm allows embedding associated data. + * + * @see https://en.wikipedia.org/wiki/Authenticated_encryption + */ public function isAead(): bool { return match ($this) { From a511bcd802ef678471788da39b25a150aa139c8e Mon Sep 17 00:00:00 2001 From: Enzo Innocenzi Date: Tue, 29 Jul 2025 18:01:18 +0200 Subject: [PATCH 12/12] refactor: make encryption key readonly --- packages/cryptography/src/Encryption/EncryptionConfig.php | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/cryptography/src/Encryption/EncryptionConfig.php b/packages/cryptography/src/Encryption/EncryptionConfig.php index 426c47f76..73bc91549 100644 --- a/packages/cryptography/src/Encryption/EncryptionConfig.php +++ b/packages/cryptography/src/Encryption/EncryptionConfig.php @@ -11,6 +11,6 @@ final class EncryptionConfig public function __construct( public EncryptionAlgorithm $algorithm, #[\SensitiveParameter] - public ?string $key, + public readonly ?string $key, ) {} }