From d169ee5f1d9229571f566136ec5879a84cf5d28f Mon Sep 17 00:00:00 2001 From: Saif Eddin G <29315886+azjezz@users.noreply.github.com> Date: Tue, 20 Oct 2020 08:45:30 +0200 Subject: [PATCH] [Hash] introduce the Hash API (#90) --- src/Psl/Hash/Context.php | 103 +++++++++++++++++++++++++ src/Psl/Hash/Hmac/algorithms.php | 20 +++++ src/Psl/Hash/Hmac/hash.php | 20 +++++ src/Psl/Hash/algorithms.php | 20 +++++ src/Psl/Hash/equals.php | 17 ++++ src/Psl/Hash/hash.php | 19 +++++ src/Psl/Internal/Loader.php | 6 ++ tests/Psl/Hash/AlgorithmsTest.php | 18 +++++ tests/Psl/Hash/ContextTest.php | 79 +++++++++++++++++++ tests/Psl/Hash/EqualsTest.php | 30 +++++++ tests/Psl/Hash/HashTest.php | 37 +++++++++ tests/Psl/Hash/Hmac/AlgorithmsTest.php | 18 +++++ tests/Psl/Hash/Hmac/HashTest.php | 44 +++++++++++ 13 files changed, 431 insertions(+) create mode 100644 src/Psl/Hash/Context.php create mode 100644 src/Psl/Hash/Hmac/algorithms.php create mode 100644 src/Psl/Hash/Hmac/hash.php create mode 100644 src/Psl/Hash/algorithms.php create mode 100644 src/Psl/Hash/equals.php create mode 100644 src/Psl/Hash/hash.php create mode 100644 tests/Psl/Hash/AlgorithmsTest.php create mode 100644 tests/Psl/Hash/ContextTest.php create mode 100644 tests/Psl/Hash/EqualsTest.php create mode 100644 tests/Psl/Hash/HashTest.php create mode 100644 tests/Psl/Hash/Hmac/AlgorithmsTest.php create mode 100644 tests/Psl/Hash/Hmac/HashTest.php diff --git a/src/Psl/Hash/Context.php b/src/Psl/Hash/Context.php new file mode 100644 index 0000000..d87b157 --- /dev/null +++ b/src/Psl/Hash/Context.php @@ -0,0 +1,103 @@ +update('The quick brown fox ') + * ->update('jumped over the lazy dog.') + * ->finalize() + * => Str("5c6ffbdd40d9556b73a21e63c3e0e904") + * + * @psalm-immutable + */ +final class Context +{ + private HashContext $internalContext; + + private function __construct(HashContext $internal_context) + { + $this->internalContext = $internal_context; + } + + /** + * Initialize an incremental hashing context. + * + * @throws Psl\Exception\InvariantViolationException If the given algorithm is unsupported. + * + * @psalm-pure + */ + public static function forAlgorithm(string $algorithm): Context + { + Psl\invariant( + Arr\contains(algorithms(), $algorithm), + 'Expected a valid hashing algorithm, "%s" given.', + $algorithm, + ); + $internal_context = hash_init($algorithm); + + return new self($internal_context); + } + + /** + * Initialize an incremental HMAC hashing context. + * + * @throws Psl\Exception\InvariantViolationException If the given algorithm is unsupported. + * + * @psalm-pure + */ + public static function hmac(string $algorithm, string $key): Context + { + Psl\invariant( + Arr\contains(Hmac\algorithms(), $algorithm), + 'Expected a hashing algorithms suitable for HMAC, "%s" given.', + $algorithm + ); + + Psl\invariant(!Str\is_empty($key), 'Expected a non-empty shared secret key.'); + + $internal_context = hash_init($algorithm, HASH_HMAC, $key); + + return new self($internal_context); + } + + /** + * Pump data into an active hashing context. + */ + public function update(string $data): Context + { + /** @var HashContext $internal_context */ + $internal_context = hash_copy($this->internalContext); + hash_update($internal_context, $data); + + return new self($internal_context); + } + + /** + * Finalize an incremental hash and return resulting digest. + */ + public function finalize(): string + { + /** @var HashContext $internal_context */ + $internal_context = hash_copy($this->internalContext); + + return hash_final($internal_context, false); + } +} diff --git a/src/Psl/Hash/Hmac/algorithms.php b/src/Psl/Hash/Hmac/algorithms.php new file mode 100644 index 0000000..bc6d69d --- /dev/null +++ b/src/Psl/Hash/Hmac/algorithms.php @@ -0,0 +1,20 @@ + + * + * @psalm-pure + */ +function algorithms(): array +{ + /** @psalm-suppress ImpureFunctionCall - hash_hmac_algos is pure. */ + return hash_hmac_algos(); +} diff --git a/src/Psl/Hash/Hmac/hash.php b/src/Psl/Hash/Hmac/hash.php new file mode 100644 index 0000000..304bced --- /dev/null +++ b/src/Psl/Hash/Hmac/hash.php @@ -0,0 +1,20 @@ +update($data)->finalize(); +} diff --git a/src/Psl/Hash/algorithms.php b/src/Psl/Hash/algorithms.php new file mode 100644 index 0000000..73ab64c --- /dev/null +++ b/src/Psl/Hash/algorithms.php @@ -0,0 +1,20 @@ + + * + * @psalm-pure + */ +function algorithms(): array +{ + /** @psalm-suppress ImpureFunctionCall - hash_algos is pure. */ + return hash_algos(); +} diff --git a/src/Psl/Hash/equals.php b/src/Psl/Hash/equals.php new file mode 100644 index 0000000..4801100 --- /dev/null +++ b/src/Psl/Hash/equals.php @@ -0,0 +1,17 @@ +update($data)->finalize(); +} diff --git a/src/Psl/Internal/Loader.php b/src/Psl/Internal/Loader.php index 787f16d..aa03791 100644 --- a/src/Psl/Internal/Loader.php +++ b/src/Psl/Internal/Loader.php @@ -331,6 +331,11 @@ final class Loader 'Psl\Password\hash', 'Psl\Password\needs_rehash', 'Psl\Password\verify', + 'Psl\Hash\hash', + 'Psl\Hash\algorithms', + 'Psl\Hash\equals', + 'Psl\Hash\Hmac\hash', + 'Psl\Hash\Hmac\algorithms', ]; public const INTERFACES = [ @@ -387,6 +392,7 @@ final class Loader 'Psl\Type\Type', 'Psl\Json\Exception\DecodeException', 'Psl\Json\Exception\EncodeException', + 'Psl\Hash\Context', ]; private const TYPE_CONSTANTS = 1; diff --git a/tests/Psl/Hash/AlgorithmsTest.php b/tests/Psl/Hash/AlgorithmsTest.php new file mode 100644 index 0000000..9fbeb93 --- /dev/null +++ b/tests/Psl/Hash/AlgorithmsTest.php @@ -0,0 +1,18 @@ +update('The quick brown fox ') + ->update('jumped over the lazy dog.'); + + static::assertSame('5c6ffbdd40d9556b73a21e63c3e0e904', $context->finalize()); + } + + public function testForAlgorithmThrowsForInvalidAlgorithm(): void + { + $this->expectException(InvariantViolationException::class); + $this->expectExceptionMessage('Expected a valid hashing algorithm, "base64" given.'); + + Hash\Context::forAlgorithm('base64'); + } + + public function testHmac(): void + { + $context = Hash\Context::hmac('md5', 'secret') + ->update('The quick brown fox ') + ->update('jumped over the lazy dog.'); + + static::assertSame('7eb2b5c37443418fc77c136dd20e859c', $context->finalize()); + } + + public function testHmacThrowsForInvalidAlgorithm(): void + { + $this->expectException(InvariantViolationException::class); + $this->expectExceptionMessage('Expected a hashing algorithms suitable for HMAC, "base64" given.'); + + Hash\Context::hmac('base64', 'secret'); + } + + public function testHmacThrowsForEmptySecretKey(): void + { + $this->expectException(InvariantViolationException::class); + $this->expectExceptionMessage('Expected a non-empty shared secret key.'); + + Hash\Context::hmac('sha1', ''); + } + + public function testContextIsImmutable(): void + { + $first = Hash\Context::forAlgorithm('md5'); + $second = $first->update('The quick brown fox '); + $third = $second->update('jumped over the lazy dog.'); + + static::assertNotSame($first, $second); + static::assertNotSame($second, $third); + static::assertNotSame($third, $first); + + static::assertSame('d41d8cd98f00b204e9800998ecf8427e', $first->finalize()); + static::assertSame('c4314972a672ded8759cafdca9af3238', $second->finalize()); + static::assertSame('5c6ffbdd40d9556b73a21e63c3e0e904', $third->finalize()); + } + + public function testContextIsStillValidAfterFinalization(): void + { + $context = Hash\Context::forAlgorithm('md5') + ->update('The quick brown fox ') + ->update('jumped over the lazy dog.'); + + static::assertSame('5c6ffbdd40d9556b73a21e63c3e0e904', $context->finalize()); + static::assertSame('5983132dd3e26f51fa8611a94c8e05ac', $context->update(' cool!')->finalize()); + } +} diff --git a/tests/Psl/Hash/EqualsTest.php b/tests/Psl/Hash/EqualsTest.php new file mode 100644 index 0000000..12b8563 --- /dev/null +++ b/tests/Psl/Hash/EqualsTest.php @@ -0,0 +1,30 @@ + + */ + public function provideEqualsData(): Generator + { + yield [true, 'hello', 'hello']; + yield [false, 'hey', 'hello']; + yield [false, 'hello', 'hey']; + } +} diff --git a/tests/Psl/Hash/HashTest.php b/tests/Psl/Hash/HashTest.php new file mode 100644 index 0000000..42abb63 --- /dev/null +++ b/tests/Psl/Hash/HashTest.php @@ -0,0 +1,37 @@ +expectException(InvariantViolationException::class); + + Hash\hash('Hello', 'base64'); + } + + /** + * @psalm-return Generator + */ + public function provideHashData(): Generator + { + yield ['2aae6c35c94fcfb415dbe95f408b9ce91ee846ed', 'hello world', 'sha1']; + yield ['5eb63bbbe01eeed093cb22bb8f5acdc3', 'hello world', 'md5']; + } +} diff --git a/tests/Psl/Hash/Hmac/AlgorithmsTest.php b/tests/Psl/Hash/Hmac/AlgorithmsTest.php new file mode 100644 index 0000000..3458aa6 --- /dev/null +++ b/tests/Psl/Hash/Hmac/AlgorithmsTest.php @@ -0,0 +1,18 @@ +expectException(InvariantViolationException::class); + + Hmac\hash('Hello', 'base64', 'real-secret'); + } + + public function testHashThrowsForEmptySharedSecret(): void + { + $this->expectException(InvariantViolationException::class); + + Hmac\hash('Hello', 'sha1', ''); + } + + /** + * @psalm-return Generator + */ + public function provideHashData(): Generator + { + yield ['03376ee7ad7bbfceee98660439a4d8b125122a5a', 'hello world', 'sha1', 'secret']; + yield ['78d6997b1230f38e59b6d1642dfaa3a4', 'hello world', 'md5', 'secret']; + } +}