diff --git a/composer.json b/composer.json index 6fb96415b..cd64ee36b 100644 --- a/composer.json +++ b/composer.json @@ -82,6 +82,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", @@ -119,6 +120,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", @@ -185,6 +187,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/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/.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/LICENSE.md b/packages/cryptography/LICENSE.md new file mode 100644 index 000000000..54215b726 --- /dev/null +++ b/packages/cryptography/LICENSE.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..40b072222 --- /dev/null +++ b/packages/cryptography/composer.json @@ -0,0 +1,22 @@ +{ + "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", + "tempest/clock": "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/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 new file mode 100644 index 000000000..724cbdbfc --- /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, 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'], 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']), + ); + } + + 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..6519a0a40 --- /dev/null +++ b/packages/cryptography/src/Encryption/EncryptionAlgorithm.php @@ -0,0 +1,41 @@ +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) { + 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..73bc91549 --- /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(base64_decode($key ?: '', strict: true), $algorithm); + } + + public function toString(): string + { + return base64_encode($this->value); + } + + public function __toString(): string + { + return $this->toString(); + } +} 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..823a571f1 --- /dev/null +++ b/packages/cryptography/src/Encryption/encryption.config.php @@ -0,0 +1,9 @@ + [ + '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 @@ + $this->config->algorithm; + } + + private SigningKey $key { + get => SigningKey::fromString($this->config->key); + } + + public function __construct( + private readonly SigningConfig $config, + private readonly Timelock $timelock, + ) {} + + public function sign(string $data): Signature + { + return new Signature(hash_hmac( + algo: $this->algorithm->value, + data: $data, + key: $this->key->value, + )); + } + + public function verify(string $data, Signature $signature): bool + { + return $this->timelock->invoke( + callback: fn () => hash_equals( + 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 new file mode 100644 index 000000000..a1af3fd7c --- /dev/null +++ b/packages/cryptography/src/Signing/Signature.php @@ -0,0 +1,22 @@ +value; + } + + public static function from(string $value): self + { + return new self($value); + } +} 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), + timelock: $container->get(Timelock::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 @@ +value; + } +} diff --git a/packages/cryptography/src/Signing/signing.config.php b/packages/cryptography/src/Signing/signing.config.php new file mode 100644 index 000000000..cc9486bca --- /dev/null +++ b/packages/cryptography/src/Signing/signing.config.php @@ -0,0 +1,10 @@ +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/CreatesSigner.php b/packages/cryptography/tests/CreatesSigner.php new file mode 100644 index 000000000..b8cefe4ad --- /dev/null +++ b/packages/cryptography/tests/CreatesSigner.php @@ -0,0 +1,25 @@ +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 new file mode 100644 index 000000000..8321df764 --- /dev/null +++ b/packages/cryptography/tests/Encryption/EncryptionTest.php @@ -0,0 +1,85 @@ +toString(); + + 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, strict: true))); + $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/Password/PasswordHasherTest.php b/packages/cryptography/tests/Password/PasswordHasherTest.php new file mode 100644 index 000000000..bf45726fb --- /dev/null +++ b/packages/cryptography/tests/Password/PasswordHasherTest.php @@ -0,0 +1,90 @@ +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/packages/cryptography/tests/Signing/SignerTest.php b/packages/cryptography/tests/Signing/SignerTest.php new file mode 100644 index 000000000..d85d4713a --- /dev/null +++ b/packages/cryptography/tests/Signing/SignerTest.php @@ -0,0 +1,188 @@ +createSigner(new SigningConfig( + algorithm: SigningAlgorithm::SHA256, + key: 'my_secret_key', + minimumExecutionDuration: false, + )); + + $data = 'important data'; + $signature = $signer->sign($data); + + $this->assertTrue($signer->verify($data, $signature)); + } + + public function test_bad_signature(): void + { + $signer = $this->createSigner(new SigningConfig( + algorithm: SigningAlgorithm::SHA256, + key: 'my_secret_key', + minimumExecutionDuration: false, + )); + + $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 = $this->createSigner(new SigningConfig( + algorithm: SigningAlgorithm::SHA256, + key: 'my_secret_key', + minimumExecutionDuration: false, + )); + + $signer2 = $this->createSigner(new SigningConfig( + algorithm: SigningAlgorithm::SHA512, + key: 'my_secret_key', + minimumExecutionDuration: false, + )); + + $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(SigningKeyWasInvalid::class); + + $signer = $this->createSigner(new SigningConfig( + algorithm: SigningAlgorithm::SHA256, + key: '', + minimumExecutionDuration: false, + )); + + $signer->sign('important data'); + } + + public function test_empty_data(): void + { + $signer = $this->createSigner(new SigningConfig( + algorithm: SigningAlgorithm::SHA256, + key: 'my_secret_key', + minimumExecutionDuration: false, + )); + + $signature = $signer->sign(''); + + // An empty string should still produce a valid signature + $this->assertTrue($signer->verify('', $signature)); + } + + public function test_consistent_signature(): void + { + $signer = $this->createSigner(new SigningConfig( + algorithm: SigningAlgorithm::SHA256, + key: 'my_secret_key', + minimumExecutionDuration: false, + )); + + $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 = $this->createSigner(new SigningConfig( + algorithm: SigningAlgorithm::SHA256, + key: 'signer1_key_foo', + minimumExecutionDuration: false, + )); + + $signer2 = $this->createSigner(new SigningConfig( + algorithm: SigningAlgorithm::SHA512, + key: 'signer2_key_bar', + minimumExecutionDuration: false, + )); + + $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)); + } + + 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.29, $elapsed); + $this->assertLessThanOrEqual(0.311, $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->assertLessThanOrEqual(1_001, $elapsed); + $this->assertGreaterThanOrEqual(999, $elapsed); + } +} diff --git a/packages/cryptography/tests/TimelockTest.php b/packages/cryptography/tests/TimelockTest.php new file mode 100644 index 000000000..977ae407c --- /dev/null +++ b/packages/cryptography/tests/TimelockTest.php @@ -0,0 +1,85 @@ +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/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 new file mode 100644 index 000000000..ae870763f --- /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)->toString(), + )); + + $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)); + } +} 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)); + } +} diff --git a/tests/Integration/Cryptography/SignerTest.php b/tests/Integration/Cryptography/SignerTest.php new file mode 100644 index 000000000..e1c79871a --- /dev/null +++ b/tests/Integration/Cryptography/SignerTest.php @@ -0,0 +1,56 @@ + $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', + minimumExecutionDuration: false, + )); + + $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', + minimumExecutionDuration: false, + )); + + $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', + minimumExecutionDuration: false, + )); + + $this->container->unregister(Signer::class); + + $this->assertFalse($this->signer->verify('important data', $signature)); + } +} 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); + } +}