From b3d85d3931eb3d428bb47293d1a285161124c8a1 Mon Sep 17 00:00:00 2001 From: Sebastiaan Stok Date: Sun, 5 Nov 2023 12:08:03 +0100 Subject: [PATCH 1/9] Clean-up old files --- .gitattributes | 3 -- .gitignore | 1 - composer.json | 4 +-- phpcs.xml.dist | 75 -------------------------------------------------- psalm.xml | 72 ------------------------------------------------ 5 files changed, 1 insertion(+), 154 deletions(-) delete mode 100644 phpcs.xml.dist delete mode 100644 psalm.xml diff --git a/.gitattributes b/.gitattributes index 327c223..1002643 100644 --- a/.gitattributes +++ b/.gitattributes @@ -6,11 +6,8 @@ core.autocrlf=lf .editorconfig export-ignore .gitattributes export-ignore .gitignore export-ignore -.travis.yml export-ignore CODE_OF_CONDUCT.md export-ignore .github export-ignore phpunit.xml.dist export-ignore -phpcs.xml.dist export-ignore phpstan.neon export-ignore -psalm.xml export-ignore Makefile export-ignore diff --git a/.gitignore b/.gitignore index d99db5a..5a216a0 100644 --- a/.gitignore +++ b/.gitignore @@ -1,5 +1,4 @@ /vendor/ /composer.lock -/phpcs.xml /var/ .phpunit.result.cache diff --git a/composer.json b/composer.json index a153b77..ba7e0ec 100644 --- a/composer.json +++ b/composer.json @@ -22,9 +22,7 @@ "paragonie/sodium_compat": "^1.8" }, "require-dev": { - "doctrine/coding-standard": "^8.0", - "phpunit/phpunit": "^7.5 || ^8.5", - "psalm/plugin-phpunit": "^0.13.0" + "phpunit/phpunit": "^7.5 || ^8.5" }, "config": { "preferred-install": { diff --git a/phpcs.xml.dist b/phpcs.xml.dist deleted file mode 100644 index b30c346..0000000 --- a/phpcs.xml.dist +++ /dev/null @@ -1,75 +0,0 @@ - - - - - - - - - - - - src - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - */Tests/* - - - - */Tests/* - - - - */Tests/* - - - - */Tests/* - - - - */Tests/* - - - - - - - - diff --git a/psalm.xml b/psalm.xml deleted file mode 100644 index 98668a5..0000000 --- a/psalm.xml +++ /dev/null @@ -1,72 +0,0 @@ - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - From 92ba66a177bcb830bae49af1a4c3ecfab81cf5c2 Mon Sep 17 00:00:00 2001 From: Sebastiaan Stok Date: Sun, 5 Nov 2023 12:15:23 +0100 Subject: [PATCH 2/9] Modernize readme --- README.md | 52 ++++++++++++++++++++++++---------------------------- 1 file changed, 24 insertions(+), 28 deletions(-) diff --git a/README.md b/README.md index dd76e39..7397144 100644 --- a/README.md +++ b/README.md @@ -3,12 +3,13 @@ Rollerworks SplitToken Component SplitToken provides a Token-Based Authentication Protocol without Side-Channels. -This technique is based of [Split Tokens: Token-Based Authentication Protocols without Side-Channels](https://paragonie.com/blog/2017/02/split-tokens-token-based-authentication-protocols-without-side-channels). +This technique is based of [Split Tokens: Token-Based Authentication Protocols without Side-Channels]. +Which was first proposed by Paragon Initiative Enterprises. SplitToken-Based Authentication is best used for password resetting or one-time -single-logon. +single-logon. -While possible, this technique is not recommended as a replacement for +While possible, this technique is not recommended as a replacement for OAuth or Json Web Tokens. ## Introduction @@ -22,45 +23,35 @@ of two parts: The **selector** (used in the query) and the **verifier** * The verifier works as a password and is only provided to the user, the database only holds a salted (cryptographic) hash of the verifier. - + The length of this value is heavily dependent on the used hashing algorithm and should not be hardcoded. - -The full token is provided to the user or recipient and functions as a combined + +The full token is provided to the user or recipient and functions as a combined identifier (selector) and password (verifier). **Caution: You NEVER store the full token as-is!** You only store the selector, and a (cryptographic) hash of the verifier. -## Requirements - -PHP 7.2 with the (lib)sodium extension enabled. - ## Installation -To install this package, add `rollerworks/split-token` to your composer.json +To install this package, add `rollerworks/split-token` to your composer.json: ```bash $ php composer.phar require rollerworks/split-token ``` -Now, Composer will automatically download all required files, and install them -for you. +Now, [Composer][composer] will automatically download all required files, +and install them for you. -**Caution:** There is no stable version of this library yet, while no major changes -are expected you are advised to upgrade as soon as possible when a new version is -released. +## Requirements -Update your `composer.json` file manually to require the latest version -(avoid using the `dev-master`). +PHP 8.1 with the sodium extension enabled (default since PHP 8). ## Basic Usage ```php generate(); // // // AGAIN, DO NOT STORE "THIS" VALUE IN THE DATABASE! Store the selector and verifier-hash instead. -// +// $authToken = $token->token(); // Returns a \ParagonIE\HiddenString\HiddenString object // Indicate when the token must expire. Note that you need to clear the token from storage yourself. @@ -95,7 +86,7 @@ $authToken->expireAt(new \DateTimeImmutable('+1 hour')); // Now to store the token cast the SplitToken to a SplitTokenValueHolder object. // // Unlike SplitToken this class is final and doesn't hold the full-token string. -// +// // Additionally you store the token with metadata (array only), // See the linked manual below for more information. $holder = $token->toValueHolder(); @@ -107,7 +98,7 @@ $holder = $token->toValueHolder(); // recovery_selector = $holder->selector(), // recovery_verifier = $holder->verifierHash(), // recovery_expires_at = $holder->expiresAt(), -// recovery_metadata = serialize($holder->metadata()), +// recovery_metadata = json_encode($holder->metadata()), // recovery_timestamp = NOW() // WHERE user_id = ... @@ -121,18 +112,18 @@ $holder = $token->toValueHolder(); $token = $splitTokenFactory->fromString($_GET['token']); // $result = SELECT user_id, recover_verifier, recovery_expires_at, recovery_metadata WHERE recover_selector = $token->selector() -$holder = new SplitTokenValueHolder($token->selector(), $result['recovery_verifier'], $result['recovery_expires_at'], unserialize($result['recovery_metadata'], ['allowed_classes' => false])); +$holder = new SplitTokenValueHolder($token->selector(), $result['recovery_verifier'], $result['recovery_expires_at'], json_decode($result['recovery_metadata'], true)); if ($token->matches($holder)) { echo 'OK, you have access'; } else { // Note: Make sure to remove the token from storage. - + echo 'NO, I cannot let you do this John.'; } ``` -Once a result is found using the selector, the stored verifier-hash is used to +Once a result is found using the selector, the stored verifier-hash is used to compute a matching hash of the provided verifier. And the values are compared in constant-time to protect against side-channel attacks. @@ -147,7 +138,7 @@ in constant-time to protect against side-channel attacks. Because of security reasons, a `SplitToken` only throws generic runtime exceptions for wrong usage, but no detailed exceptions about invalid input. -In the case of an error the memory allocation of the verifier and full token +In the case of an error the memory allocation of the verifier and full token is zeroed to prevent leakage during a core dump or unhandled exception. ## Versioning @@ -178,3 +169,8 @@ The Split Token idea was first proposed by Paragon Initiative Enterprises. The Source Code of this package is subject to the terms of the Mozilla Public License, version 2.0 ([MPLv2.0 License](LICENSE)). + +Which can be safely used with any other license including MIT +and GNU GPL. + +[Split Tokens: Token-Based Authentication Protocols without Side-Channels]: https://paragonie.com/blog/2017/02/split-tokens-token-based-authentication-protocols-without-side-channels From 42824fce1fcdde4cecf8a31ed73b606679d99953 Mon Sep 17 00:00:00 2001 From: Sebastiaan Stok Date: Sun, 5 Nov 2023 13:40:23 +0100 Subject: [PATCH 3/9] Drop support for PHP 7/8.1 --- .gitattributes | 9 +++--- .gitignore | 9 ++++-- .php-cs-fixer.dist.php | 27 +++++++++++++++++ Makefile | 55 ++-------------------------------- composer.json | 38 +++++++++++------------ phpunit.xml.dist | 37 +++++++++++------------ tests/Argon2SplitTokenTest.php | 2 +- 7 files changed, 78 insertions(+), 99 deletions(-) create mode 100644 .php-cs-fixer.dist.php diff --git a/.gitattributes b/.gitattributes index 1002643..93aa015 100644 --- a/.gitattributes +++ b/.gitattributes @@ -1,13 +1,14 @@ # Always use LF core.autocrlf=lf -/doc export-ignore -/tests export-ignore .editorconfig export-ignore .gitattributes export-ignore .gitignore export-ignore -CODE_OF_CONDUCT.md export-ignore .github export-ignore +.php-cs-fixer.dist.php export-ignore +CODE_OF_CONDUCT.md export-ignore +Makefile export-ignore phpunit.xml.dist export-ignore phpstan.neon export-ignore -Makefile export-ignore +phpstan-baseline.neon export-ignore +tests/ export-ignore diff --git a/.gitignore b/.gitignore index 5a216a0..e2245b8 100644 --- a/.gitignore +++ b/.gitignore @@ -1,4 +1,9 @@ +composer.lock /vendor/ -/composer.lock -/var/ + +phpunit.xml .phpunit.result.cache +.phpunit.cache/ +.phpunit + +.php-cs-fixer.cache diff --git a/.php-cs-fixer.dist.php b/.php-cs-fixer.dist.php new file mode 100644 index 0000000..a11319f --- /dev/null +++ b/.php-cs-fixer.dist.php @@ -0,0 +1,27 @@ +in([ + __DIR__ . '/src', + __DIR__ . '/tests', + ]); + +$config = new PhpCsFixer\Config(); +$config + ->setRiskyAllowed(true) + ->setRules( + array_merge( + require __DIR__ . '/vendor/rollerscapes/standards/php-cs-fixer-rules.php', + ['header_comment' => ['header' => $header]]) + ) + ->setFinder($finder); + +return $config; diff --git a/Makefile b/Makefile index 15bc092..2ba1948 100644 --- a/Makefile +++ b/Makefile @@ -1,55 +1,4 @@ -ifndef BUILD_ENV -BUILD_ENV=php7.2 -endif - -QA_DOCKER_IMAGE=jakzal/phpqa:1.34.1-php7.4-alpine -QA_DOCKER_COMMAND=docker run --init -t --rm --user "$(shell id -u):$(shell id -g)" --env "COMPOSER_HOME=/composer" --volume /tmp/tmp-phpqa-$(shell id -u):/tmp:delegated --volume "$(shell pwd):/project:delegated" --volume "${HOME}/.composer:/composer:delegated" --workdir /project ${QA_DOCKER_IMAGE} - -install: composer-install -dist: composer-validate cs phpstan psalm test -ci: check test -check: composer-validate cs-check phpstan psalm -test: phpunit-coverage - -clean: - rm -rf var/ - -composer-validate: ensure - sh -c "${QA_DOCKER_COMMAND} composer validate" - -composer-install: fetch ensure clean - sh -c "${QA_DOCKER_COMMAND} composer upgrade" - -composer-install-lowest: fetch ensure clean - sh -c "${QA_DOCKER_COMMAND} composer upgrade --prefer-lowest" - -composer-install-dev: fetch ensure clean - rm -f composer.lock - cp composer.json _composer.json - sh -c "${QA_DOCKER_COMMAND} composer config minimum-stability dev" - sh -c "${QA_DOCKER_COMMAND} composer upgrade --no-progress --no-interaction --no-suggest --optimize-autoloader --ansi" - mv _composer.json composer.json - -cs: - sh -c "${QA_DOCKER_COMMAND} php vendor/bin/phpcbf" - -cs-check: - sh -c "${QA_DOCKER_COMMAND} php vendor/bin/phpcs" - -phpstan: ensure - sh -c "${QA_DOCKER_COMMAND} phpstan analyse" - -psalm: ensure - sh -c "${QA_DOCKER_COMMAND} psalm --show-info=false" - -phpunit-coverage: ensure - sh -c "${QA_DOCKER_COMMAND} phpdbg -qrr vendor/bin/phpunit --verbose --coverage-text --log-junit=var/phpunit.junit.xml --coverage-xml var/coverage-xml/" +include vendor/rollerscapes/standards/Makefile phpunit: - sh -c "${QA_DOCKER_COMMAND} phpunit --verbose" - -ensure: - mkdir -p ${HOME}/.composer /tmp/tmp-phpqa-$(shell id -u) - -fetch: - docker pull "${QA_DOCKER_IMAGE}" + ./vendor/bin/phpunit diff --git a/composer.json b/composer.json index ba7e0ec..2a6ec61 100644 --- a/composer.json +++ b/composer.json @@ -1,40 +1,32 @@ { "name": "rollerworks/split-token", - "type": "library", "description": "Token-Based Authentication Protocol without Side-Channels", + "license": "MPL-2.0", + "type": "library", "keywords": [ "token", "crypto", "rollerworks" ], - "homepage": "https://rollerworks.github.io", - "license": "MPL-2.0", "authors": [ { "name": "Sebastiaan Stok", "email": "s.stok@rollerscapes.net" } ], + "homepage": "https://rollerworks.github.io", "require": { - "php": ">=7.2", - "paragonie/constant_time_encoding": "^2.2", - "paragonie/hidden-string": "^1.0 || ^2.0", - "paragonie/sodium_compat": "^1.8" + "php": ">=8.2", + "paragonie/constant_time_encoding": "^2.6", + "paragonie/hidden-string": "^2.0" }, "require-dev": { - "phpunit/phpunit": "^7.5 || ^8.5" - }, - "config": { - "preferred-install": { - "*": "dist" - }, - "sort-packages": true - }, - "extra": { - "branch-alias": { - "dev-main": "0.1-dev" - } + "doctrine/instantiator": "^2.0", + "phpunit/phpunit": "^10.4", + "rollerscapes/standards": "^1.0" }, + "minimum-stability": "dev", + "prefer-stable": true, "autoload": { "psr-4": { "Rollerworks\\Component\\SplitToken\\": "src" @@ -47,5 +39,13 @@ "psr-4": { "Rollerworks\\Component\\SplitToken\\Tests\\": "tests" } + }, + "config": { + "sort-packages": true + }, + "extra": { + "branch-alias": { + "dev-main": "1.0-dev" + } } } diff --git a/phpunit.xml.dist b/phpunit.xml.dist index 3f2eff2..b040071 100644 --- a/phpunit.xml.dist +++ b/phpunit.xml.dist @@ -1,32 +1,29 @@ - - - tests/ + + tests - + - - + + + src/ - - vendor/ - tests/ - - - + + + vendor/ + tests/ + + diff --git a/tests/Argon2SplitTokenTest.php b/tests/Argon2SplitTokenTest.php index 2904299..6a0fa44 100644 --- a/tests/Argon2SplitTokenTest.php +++ b/tests/Argon2SplitTokenTest.php @@ -91,7 +91,7 @@ public function it_creates_a_split_token_with_custom_config() 'threads' => 1, ]); - self::assertRegExp('/^\$argon2[id]+\$v=19\$m=512,t=1,p=1/', $token = $splitToken->toValueHolder()->verifierHash()); + self::assertMatchesRegularExpression('/^\$argon2[id]+\$v=19\$m=512,t=1,p=1/', $splitToken->toValueHolder()->verifierHash()); } /** From 2f9e1afd41015300aa368284e5f78eb5aa3a7129 Mon Sep 17 00:00:00 2001 From: Sebastiaan Stok Date: Sun, 5 Nov 2023 14:02:57 +0100 Subject: [PATCH 4/9] Correct CS --- doc/configuring-hasher.md | 10 ++- phpstan.neon | 18 ++--- src/Argon2SplitToken.php | 28 +++---- src/Argon2SplitTokenFactory.php | 12 +-- src/FakeSplitToken.php | 2 - src/FakeSplitTokenFactory.php | 10 +-- src/SplitToken.php | 92 +++++++++-------------- src/SplitTokenFactory.php | 1 - src/SplitTokenValueHolder.php | 22 +++--- tests/Argon2SplitTokenFactoryTest.php | 17 ++--- tests/Argon2SplitTokenTest.php | 101 +++++++++----------------- tests/SplitTokenValueHolderTest.php | 51 +++++++------ 12 files changed, 151 insertions(+), 213 deletions(-) diff --git a/doc/configuring-hasher.md b/doc/configuring-hasher.md index b41be27..f3aff2f 100644 --- a/doc/configuring-hasher.md +++ b/doc/configuring-hasher.md @@ -3,12 +3,14 @@ Configuring the hasher **Note:** Only the `Argon2SplitTokenFactory` can be configured. -To configure a SplitToken factory pass an array associative array of options +To configure a SplitToken factory pass an associative array of options to the Factory constructor. -* 'memory_cost': amount of memory in bytes that Argon2lib will use while trying to compute a hash. -* 'time_cost': amount of time that Argon2lib will spend trying to compute a hash. -* 'threads': number of threads that Argon2lib will use. +| Option | Description | +|----------------|-----------------------------------------------------------------------------------| +| 'memory_cost' | amount of memory in bytes that Argon2lib will use while trying to compute a hash. | +| 'time_cost' | amount of time that Argon2lib will spend trying to compute a hash. | +| 'threads' | number of threads that Argon2lib will use. | ```php $splitTokenFactory = new Argon2SplitTokenFactory([ diff --git a/phpstan.neon b/phpstan.neon index 543d5bb..0e56097 100644 --- a/phpstan.neon +++ b/phpstan.neon @@ -1,18 +1,16 @@ includes: - - /tools/.composer/vendor-bin/phpstan/vendor/phpstan/phpstan-phpunit/extension.neon - - /tools/.composer/vendor-bin/phpstan/vendor/jangregor/phpstan-prophecy/src/extension.neon + - vendor/rollerscapes/standards/phpstan.neon + #- phpstan-baseline.neon parameters: #reportUnmatchedIgnoredErrors: false - tmpDir: %currentWorkingDirectory%/var/phpstan - level: max paths: - ./src - excludes_analyse: - - vendor/ - - %currentWorkingDirectory%/tests/** + - ./tests + excludePaths: + - var/ + - templates/ + - translations/ - checkNullables: false # To many false positives - - # ignoreErrors: + #ignoreErrors: diff --git a/src/Argon2SplitToken.php b/src/Argon2SplitToken.php index 19e17aa..293259d 100644 --- a/src/Argon2SplitToken.php +++ b/src/Argon2SplitToken.php @@ -10,24 +10,20 @@ namespace Rollerworks\Component\SplitToken; -use RuntimeException; -use const PASSWORD_ARGON2_DEFAULT_MEMORY_COST; -use const PASSWORD_ARGON2_DEFAULT_THREADS; -use const PASSWORD_ARGON2_DEFAULT_TIME_COST; -use const PASSWORD_ARGON2I; -use function array_merge; -use function password_hash; -use function password_verify; - +/** + * Don't create this class directly, use {@see Argon2SplitTokenFactory} + * to create a new instance instead. + */ final class Argon2SplitToken extends SplitToken { - protected function configureHasher(array $config = []) + /** @param array $config */ + protected function configureHasher(array $config = []): void { $this->config = array_merge( [ - 'memory_cost' => PASSWORD_ARGON2_DEFAULT_MEMORY_COST, - 'time_cost' => PASSWORD_ARGON2_DEFAULT_TIME_COST, - 'threads' => PASSWORD_ARGON2_DEFAULT_THREADS, + 'memory_cost' => \PASSWORD_ARGON2_DEFAULT_MEMORY_COST, + 'time_cost' => \PASSWORD_ARGON2_DEFAULT_TIME_COST, + 'threads' => \PASSWORD_ARGON2_DEFAULT_THREADS, ], $config ); @@ -40,10 +36,10 @@ protected function verifyHash(string $hash, string $verifier): bool protected function hashVerifier(string $verifier): string { - $passwordHash = password_hash($verifier, PASSWORD_ARGON2I, $this->config); + $passwordHash = password_hash($verifier, \PASSWORD_ARGON2I, $this->config); - if ($passwordHash === false) { - throw new RuntimeException('Unrecoverable password hashing error.'); + if ($passwordHash === false || $passwordHash === null) { + throw new \RuntimeException('Unrecoverable password hashing error.'); } return $passwordHash; diff --git a/src/Argon2SplitTokenFactory.php b/src/Argon2SplitTokenFactory.php index c1121ce..39ecc40 100644 --- a/src/Argon2SplitTokenFactory.php +++ b/src/Argon2SplitTokenFactory.php @@ -10,12 +10,10 @@ namespace Rollerworks\Component\SplitToken; -use DateTimeImmutable; use ParagonIE\HiddenString\HiddenString; -use function random_bytes; /** - * Uses (Lib)sodium Argon2i(d) for hashing the SplitToken verifier. + * Uses sodium Argon2id for hashing the SplitToken verifier. * * Configuration accepts the following (all integer): * @@ -28,12 +26,10 @@ final class Argon2SplitTokenFactory implements SplitTokenFactory private $config; private $defaultExpirationTimestamp; - /** - * @param int[] $config - */ - public function __construct(array $config = [], ?DateTimeImmutable $defaultExpirationTimestamp = null) + /** @param array{'memory_cost'|'time_cost'|'threads': int} $config */ + public function __construct(array $config = [], \DateTimeImmutable $defaultExpirationTimestamp = null) { - $this->config = $config; + $this->config = $config; $this->defaultExpirationTimestamp = $defaultExpirationTimestamp; } diff --git a/src/FakeSplitToken.php b/src/FakeSplitToken.php index aa78c11..0309647 100644 --- a/src/FakeSplitToken.php +++ b/src/FakeSplitToken.php @@ -10,8 +10,6 @@ namespace Rollerworks\Component\SplitToken; -use function sha1; - /** * !! THIS IMPLEMENTATION IS NOT SECURE, USE ONLY FOR TESTING !! */ diff --git a/src/FakeSplitTokenFactory.php b/src/FakeSplitTokenFactory.php index d8db317..3b645f2 100644 --- a/src/FakeSplitTokenFactory.php +++ b/src/FakeSplitTokenFactory.php @@ -11,8 +11,6 @@ namespace Rollerworks\Component\SplitToken; use ParagonIE\HiddenString\HiddenString; -use function hex2bin; -use function random_bytes; /** * Always uses the same non-random value for the SplitToken to speed-up tests. @@ -21,13 +19,13 @@ */ final class FakeSplitTokenFactory implements SplitTokenFactory { - public const SELECTOR = '1zUeXUvr4LKymANBB_bLEqiP5GPr-Pha'; - public const VERIFIER = '_OR6OOnV1o8Vy_rWhDoxKNIt'; + public const SELECTOR = '1zUeXUvr4LKymANBB_bLEqiP5GPr-Pha'; + public const VERIFIER = '_OR6OOnV1o8Vy_rWhDoxKNIt'; public const FULL_TOKEN = self::SELECTOR . self::VERIFIER; private $randomValue; - public static function instance(?string $randomValue = null): self + public static function instance(string $randomValue = null): self { return new self($randomValue); } @@ -37,7 +35,7 @@ public static function randomInstance(): self return new self(random_bytes(FakeSplitToken::TOKEN_DATA_LENGTH)); } - public function __construct(?string $randomValue = null) + public function __construct(string $randomValue = null) { $this->randomValue = $randomValue ?? hex2bin('d7351e5d4bebe0b2b298034107f6cb12a88fe463ebf8f85afce47a38e9d5d68f15cbfad6843a3128d22d'); } diff --git a/src/SplitToken.php b/src/SplitToken.php index d382ddf..1ab8c12 100644 --- a/src/SplitToken.php +++ b/src/SplitToken.php @@ -10,17 +10,15 @@ namespace Rollerworks\Component\SplitToken; -use DateTimeImmutable; use ParagonIE\ConstantTime\Base64UrlSafe; use ParagonIE\ConstantTime\Binary; use ParagonIE\HiddenString\HiddenString; -use RuntimeException; -use function sodium_memzero; -use function sprintf; /** * A split-token value-object. * + * Don't create directly, use a specific SplitTokenFactory. + * * Caution before working on this class understand that any change can * potentially introduce a security problem. Please consult a security * expert before accepting these changes as-is: @@ -40,6 +38,8 @@ * compared in *constant-time* for equality. * * The 'full token' is to be shared with the receiver only! + * Use a {@see HiddenString} object to prevent leaking the token + * in a core-dump or system log. * * THE TOKEN HOLDS THE ORIGINAL "VERIFIER", DO NOT STORE THE TOKEN * IN A STORAGE DIRECTLY, UNLESS A PROPER FORM OF ENCRYPTION IS USED! @@ -55,7 +55,8 @@ * // The $authToken is to be shared with the receiver (eg. the user) only. * // And is URI safe. * // - * // DO NOT STORE "THIS" VALUE IN THE DATABASE! Store the selector and verifier-hash instead. + * // DO NOT STORE "THIS" VALUE IN THE DATABASE! Store the selector and verifier-hash + * // as separate fields instead. * $authToken = $token->token(); // HiddenString * * $holder = $token->toValueHolder(); @@ -83,58 +84,46 @@ */ abstract class SplitToken { - public const SELECTOR_BYTES = 24; - public const VERIFIER_BYTES = 18; + public const SELECTOR_BYTES = 24; + public const VERIFIER_BYTES = 18; public const TOKEN_DATA_LENGTH = (self::VERIFIER_BYTES + self::SELECTOR_BYTES); public const TOKEN_CHAR_LENGTH = (self::SELECTOR_BYTES * 4 / 3) + (self::VERIFIER_BYTES * 4 / 3); - /** @var array */ - protected $config = []; - - /** @var HiddenString */ - private $token; - - /** @var string */ - private $selector; - - /** @var string */ - private $verifier; - - /** @var string|null */ - private $verifierHash; - - /** @var DateTimeImmutable|null */ - private $expiresAt; + /** @var array */ + protected array $config = []; + private HiddenString $token; + private string $selector; + private string $verifier; + private ?string $verifierHash = null; + private ?\DateTimeImmutable $expiresAt = null; private function __construct(HiddenString $token, string $selector, string $verifier) { - $this->token = $token; + $this->token = $token; $this->selector = $selector; $this->verifier = $verifier; } /** - * Creates a new SplitToken object based of the $token. + * Creates a new SplitToken object based of the $randomBytes. * * The $randomBytes argument must provide a crypto-random string (wrapped in - * a HiddenString object) of exactly {@see static::getLength()} bytes. + * a HiddenString object) of exactly {@see static::TOKEN_DATA_LENGTH} bytes. * - * @param mixed[] $config Configuration for the hasher method (implementation specific) - * - * @return static + * @param array $config Configuration for the hasher method (implementation specific) */ - public static function create(HiddenString $randomBytes, array $config = []) + public static function create(HiddenString $randomBytes, array $config = []): static { $bytesString = $randomBytes->getString(); if (Binary::safeStrlen($bytesString) < self::TOKEN_DATA_LENGTH) { // Don't zero memory as the value is invalid. - throw new RuntimeException(sprintf('Invalid token-data provided, expected exactly %s bytes.', static::VERIFIER_BYTES + static::SELECTOR_BYTES)); + throw new \RuntimeException(sprintf('Invalid token-data provided, expected exactly %s bytes.', self::TOKEN_DATA_LENGTH)); } $selector = Base64UrlSafe::encode(Binary::safeSubstr($bytesString, 0, self::SELECTOR_BYTES)); $verifier = Base64UrlSafe::encode(Binary::safeSubstr($bytesString, self::SELECTOR_BYTES, self::VERIFIER_BYTES)); - $token = new HiddenString($selector . $verifier, false, true); + $token = new HiddenString($selector . $verifier, false, true); $instance = new static($token, $selector, $verifier); $instance->configureHasher($config); @@ -147,12 +136,9 @@ public static function create(HiddenString $randomBytes, array $config = []) return $instance; } - /** - * @return static - */ - public function expireAt(?DateTimeImmutable $expiresAt = null) + public function expireAt(\DateTimeImmutable $expiresAt = null): static { - $instance = clone $this; + $instance = clone $this; $instance->expiresAt = $expiresAt; return $instance; @@ -162,21 +148,19 @@ public function expireAt(?DateTimeImmutable $expiresAt = null) * Recreates a SplitToken object from a string. * * Note: The provided $token is zeroed from memory when it's length is valid. - * - * @return static */ - final public static function fromString(string $token) + final public static function fromString(string $token): static { if (Binary::safeStrlen($token) < self::TOKEN_CHAR_LENGTH) { // Don't zero memory as the value is invalid. - throw new RuntimeException('Invalid token provided.'); + throw new \RuntimeException('Invalid token provided.'); } $selector = Binary::safeSubstr($token, 0, 32); $verifier = Binary::safeSubstr($token, 32); $instance = new static(new HiddenString($token), $selector, $verifier); - // Don't (re)generate as this needs the salt of the stored hash. + // Don't generate hash, as the verifier needs the salt of the stored hash. $instance->verifierHash = null; sodium_memzero($token); @@ -184,17 +168,13 @@ final public static function fromString(string $token) return $instance; } - /** - * Returns the selector to identify the token in storage. - */ + /** Returns the selector to identify the token in storage. */ public function selector(): string { return $this->selector; } - /** - * Returns the full token (selector + verifier) for authentication. - */ + /** Returns the full token (selector + verifier) for authentication. */ public function token(): HiddenString { return $this->token; @@ -216,7 +196,6 @@ final public function matches(?SplitTokenValueHolder $token): bool return false; } - /** @psalm-suppress PossiblyNullArgument */ return $this->verifyHash($token->verifierHash(), $this->verifier); } @@ -225,12 +204,12 @@ final public function matches(?SplitTokenValueHolder $token): bool * * Note: This method doesn't work when reconstructed from a string. * - * @param mixed[] $metadata Metadata for storage + * @param array $metadata Metadata for storage */ public function toValueHolder(array $metadata = []): SplitTokenValueHolder { if ($this->verifierHash === null) { - throw new RuntimeException('toValueHolder() does not work SplitToken object created with fromString().'); + throw new \RuntimeException('toValueHolder() does not work with a SplitToken object when created with fromString().'); } return new SplitTokenValueHolder($this->selector, $this->verifierHash, $this->expiresAt, $metadata); @@ -240,13 +219,14 @@ public function toValueHolder(array $metadata = []): SplitTokenValueHolder * Compares if both objects are the same. * * Warning this method leaks timing information and the expiration date is ignored! + * Use {@see matches()} for checking validity instead. */ public function equals(self $other): bool { return $other->selector === $this->selector && $other->verifierHash === $this->verifierHash; } - public function getExpirationTime(): ?DateTimeImmutable + public function getExpirationTime(): ?\DateTimeImmutable { return $this->expiresAt; } @@ -255,7 +235,7 @@ public function getExpirationTime(): ?DateTimeImmutable * This method is called in create() before the verifier is hashed, * allowing to set-up configuration for the hashing method. */ - protected function configureHasher(array $config) + protected function configureHasher(array $config): void { // no-op } @@ -269,8 +249,6 @@ protected function configureHasher(array $config) */ abstract protected function verifyHash(string $hash, string $verifier): bool; - /** - * Produces a hashed version of the verifier. - */ + /** Produces a hashed version of the verifier. */ abstract protected function hashVerifier(string $verifier): string; } diff --git a/src/SplitTokenFactory.php b/src/SplitTokenFactory.php index 7fc48c8..090751b 100644 --- a/src/SplitTokenFactory.php +++ b/src/SplitTokenFactory.php @@ -10,7 +10,6 @@ namespace Rollerworks\Component\SplitToken; - interface SplitTokenFactory { /** diff --git a/src/SplitTokenValueHolder.php b/src/SplitTokenValueHolder.php index e1a4e2d..fca5a91 100644 --- a/src/SplitTokenValueHolder.php +++ b/src/SplitTokenValueHolder.php @@ -10,8 +10,6 @@ namespace Rollerworks\Component\SplitToken; -use DateTimeImmutable; - /** * SplitToken keeps SplitToken information for storage. * @@ -34,12 +32,12 @@ final class SplitTokenValueHolder private $expiresAt; private $metadata = []; - public function __construct(string $selector, string $verifierHash, ?DateTimeImmutable $expiresAt = null, array $metadata = []) + public function __construct(string $selector, string $verifierHash, \DateTimeImmutable $expiresAt = null, array $metadata = []) { - $this->selector = $selector; + $this->selector = $selector; $this->verifierHash = $verifierHash; - $this->expiresAt = $expiresAt; - $this->metadata = $metadata; + $this->expiresAt = $expiresAt; + $this->metadata = $metadata; } public static function isEmpty(?self $valueHolder): bool @@ -90,16 +88,16 @@ public function metadata(): array return $this->metadata ?? []; } - public function isExpired(?DateTimeImmutable $datetime = null): bool + public function isExpired(\DateTimeImmutable $datetime = null): bool { if ($this->expiresAt === null) { return false; } - return $this->expiresAt->getTimestamp() < ($datetime ?? new DateTimeImmutable())->getTimestamp(); + return $this->expiresAt->getTimestamp() < ($datetime ?? new \DateTimeImmutable())->getTimestamp(); } - public function expiresAt(): ?DateTimeImmutable + public function expiresAt(): ?\DateTimeImmutable { return $this->expiresAt; } @@ -111,8 +109,8 @@ public function expiresAt(): ?DateTimeImmutable */ public function equals(self $other): bool { - return $other->selector === $this->selector && - $other->verifierHash === $this->verifierHash && - $other->metadata === $this->metadata; + return $other->selector === $this->selector + && $other->verifierHash === $this->verifierHash + && $other->metadata === $this->metadata; } } diff --git a/tests/Argon2SplitTokenFactoryTest.php b/tests/Argon2SplitTokenFactoryTest.php index 000929d..4c529a9 100644 --- a/tests/Argon2SplitTokenFactoryTest.php +++ b/tests/Argon2SplitTokenFactoryTest.php @@ -10,6 +10,7 @@ namespace Rollerworks\Component\SplitToken\Tests; +use PHPUnit\Framework\Attributes\Test; use PHPUnit\Framework\TestCase; use Rollerworks\Component\SplitToken\Argon2SplitTokenFactory; @@ -18,12 +19,10 @@ */ final class Argon2SplitTokenFactoryTest extends TestCase { - /** - * @test - */ + #[Test] public function it_generates_a_new_token_on_every_call() { - $factory = new Argon2SplitTokenFactory(); + $factory = new Argon2SplitTokenFactory(); $splitToken1 = $factory->generate(); $splitToken2 = $factory->generate(); @@ -31,14 +30,12 @@ public function it_generates_a_new_token_on_every_call() self::assertNotEquals($splitToken1, $splitToken2); } - /** - * @test - */ + #[Test] public function it_creates_from_string() { - $factory = new Argon2SplitTokenFactory(); - $splitToken = $factory->generate(); - $fullToken = $splitToken->token()->getString(); + $factory = new Argon2SplitTokenFactory(); + $splitToken = $factory->generate(); + $fullToken = $splitToken->token()->getString(); $splitTokenFromString = $factory->fromString($fullToken); self::assertTrue($splitTokenFromString->matches($splitToken->toValueHolder())); diff --git a/tests/Argon2SplitTokenTest.php b/tests/Argon2SplitTokenTest.php index 6a0fa44..1a5d178 100644 --- a/tests/Argon2SplitTokenTest.php +++ b/tests/Argon2SplitTokenTest.php @@ -10,12 +10,11 @@ namespace Rollerworks\Component\SplitToken\Tests; -use DateTimeImmutable; use ParagonIE\HiddenString\HiddenString; +use PHPUnit\Framework\Attributes\BeforeClass; +use PHPUnit\Framework\Attributes\Test; use PHPUnit\Framework\TestCase; use Rollerworks\Component\SplitToken\Argon2SplitToken as SplitToken; -use RuntimeException; -use function hex2bin; /** * @internal @@ -23,66 +22,54 @@ final class Argon2SplitTokenTest extends TestCase { private const FULL_TOKEN = '1zUeXUvr4LKymANBB_bLEqiP5GPr-Pha_OR6OOnV1o8Vy_rWhDoxKNIt'; - private const SELECTOR = '1zUeXUvr4LKymANBB_bLEqiP5GPr-Pha'; + private const SELECTOR = '1zUeXUvr4LKymANBB_bLEqiP5GPr-Pha'; private static $randValue; - /** - * @beforeClass - */ + #[BeforeClass] public static function createRandomBytes() { self::$randValue = new HiddenString(hex2bin('d7351e5d4bebe0b2b298034107f6cb12a88fe463ebf8f85afce47a38e9d5d68f15cbfad6843a3128d22d'), false, true); } - /** - * @test - */ + #[Test] public function it_validates_the_correct_length() { - $this->expectException(RuntimeException::class); + $this->expectException(\RuntimeException::class); $this->expectExceptionMessage('Invalid token-data provided, expected exactly 42 bytes.'); SplitToken::create(new HiddenString('NanananaBatNan', false, true)); } - /** - * @test - */ + #[Test] public function it_creates_a_split_token_without_id() { $splitToken = SplitToken::create(self::$randValue); - self::assertEquals(self::FULL_TOKEN, $token = $splitToken->token()->getString()); + self::assertEquals(self::FULL_TOKEN, $token = $splitToken->token()->getString()); self::assertEquals(self::SELECTOR, $selector = $splitToken->selector()); } - /** - * @test - */ + #[Test] public function it_creates_a_split_token_with_id() { $splitToken = SplitToken::create($fullToken = self::$randValue); - self::assertEquals(self::FULL_TOKEN, $token = $splitToken->token()->getString()); + self::assertEquals(self::FULL_TOKEN, $token = $splitToken->token()->getString()); self::assertEquals(self::SELECTOR, $selector = $splitToken->selector()); } - /** - * @test - */ + #[Test] public function it_compares_two_split_tokens() { $splitToken1 = SplitToken::create(self::$randValue); self::assertTrue($splitToken1->equals($splitToken1)); - self::assertTrue($splitToken1->equals($splitToken1->expireAt(new DateTimeImmutable('+5 seconds')))); + self::assertTrue($splitToken1->equals($splitToken1->expireAt(new \DateTimeImmutable('+5 seconds')))); self::assertFalse($splitToken1->equals(SplitToken::create(self::$randValue))); } - /** - * @test - */ + #[Test] public function it_creates_a_split_token_with_custom_config() { $splitToken = SplitToken::create(self::$randValue, [ @@ -94,10 +81,8 @@ public function it_creates_a_split_token_with_custom_config() self::assertMatchesRegularExpression('/^\$argon2[id]+\$v=19\$m=512,t=1,p=1/', $splitToken->toValueHolder()->verifierHash()); } - /** - * @test - */ - public function it_produces_a_SplitTokenValueHolder() + #[Test] + public function it_produces_a__split_token_value_holder() { $splitToken = SplitToken::create(self::$randValue); @@ -107,27 +92,23 @@ public function it_produces_a_SplitTokenValueHolder() self::assertStringStartsWith('$argon2i', $value->verifierHash()); self::assertEquals([], $value->metadata()); self::assertFalse($value->isExpired()); - self::assertFalse($value->isExpired(new DateTimeImmutable('-5 minutes'))); + self::assertFalse($value->isExpired(new \DateTimeImmutable('-5 minutes'))); } - /** - * @test - */ - public function it_produces_a_SplitTokenValueHolder_with_metadata() + #[Test] + public function it_produces_a__split_token_value_holder_with_metadata() { $splitToken = SplitToken::create(self::$randValue); - $value = $splitToken->toValueHolder(['he' => 'now']); + $value = $splitToken->toValueHolder(['he' => 'now']); self::assertStringStartsWith('$argon2i', $value->verifierHash()); self::assertEquals(['he' => 'now'], $value->metadata()); } - /** - * @test - */ - public function it_produces_a_SplitTokenValueHolder_with_expiration() + #[Test] + public function it_produces_a__split_token_value_holder_with_expiration() { - $date = new DateTimeImmutable('+5 minutes'); + $date = new \DateTimeImmutable('+5 minutes'); $splitToken = SplitToken::create($fullToken = self::$randValue)->expireAt($date); $value = $splitToken->toValueHolder(); @@ -137,9 +118,7 @@ public function it_produces_a_SplitTokenValueHolder_with_expiration() self::assertEquals([], $value->metadata()); } - /** - * @test - */ + #[Test] public function it_reconstructs_from_string() { $splitTokenReconstituted = SplitToken::fromString(self::FULL_TOKEN); @@ -148,21 +127,17 @@ public function it_reconstructs_from_string() self::assertEquals(self::SELECTOR, $splitTokenReconstituted->selector()); } - /** - * @test - */ + #[Test] public function it_fails_when_creating_holder_with_string_constructed() { - $this->expectException(RuntimeException::class); - $this->expectExceptionMessage('toValueHolder() does not work SplitToken object created with fromString().'); + $this->expectException(\RuntimeException::class); + $this->expectExceptionMessage('toValueHolder() does not work with a SplitToken object when created with fromString().'); SplitToken::fromString(self::FULL_TOKEN)->toValueHolder(); } - /** - * @test - */ - public function it_verifies_SplitToken() + #[Test] + public function it_verifies__split_token() { // Stored. $splitTokenHolder = SplitToken::create(self::$randValue)->toValueHolder(); @@ -173,20 +148,16 @@ public function it_verifies_SplitToken() self::assertTrue($fromString->matches($splitTokenHolder)); } - /** - * @test - */ - public function it_verifies_SplitToken_from_string_and_no_current_token_set() + #[Test] + public function it_verifies__split_token_from_string_and_no_current_token_set() { $fromString = SplitToken::fromString(self::FULL_TOKEN); self::assertFalse($fromString->matches(null)); } - /** - * @test - */ - public function it_verifies_SplitToken_from_string_selector() + #[Test] + public function it_verifies__split_token_from_string_selector() { // Stored. $splitTokenHolder = SplitToken::create(self::$randValue)->toValueHolder(); @@ -198,14 +169,12 @@ public function it_verifies_SplitToken_from_string_selector() self::assertFalse($fromString->matches($splitTokenHolder)); } - /** - * @test - */ - public function it_verifies_SplitToken_from_string_with_expiration() + #[Test] + public function it_verifies__split_token_from_string_with_expiration() { // Stored. $splitTokenHolder = SplitToken::create(self::$randValue) - ->expireAt(new DateTimeImmutable('-5 minutes')) + ->expireAt(new \DateTimeImmutable('-5 minutes')) ->toValueHolder(); // Reconstructed. diff --git a/tests/SplitTokenValueHolderTest.php b/tests/SplitTokenValueHolderTest.php index 9d36390..0e4d2a4 100644 --- a/tests/SplitTokenValueHolderTest.php +++ b/tests/SplitTokenValueHolderTest.php @@ -10,8 +10,8 @@ namespace Rollerworks\Component\SplitToken\Tests; -use DateTimeImmutable; use Doctrine\Instantiator\Instantiator; +use PHPUnit\Framework\Attributes\Test; use PHPUnit\Framework\TestCase; use Rollerworks\Component\SplitToken\SplitTokenValueHolder; @@ -23,7 +23,7 @@ final class SplitTokenValueHolderTest extends TestCase private const SELECTOR = '1zUeXUvr4LKymANBB_bLEqiP5GPr-Pha'; private const VERIFIER = '_OR6OOnV1o8Vy_rWhDoxKNIt'; - /** @test */ + #[Test] public function its_empty_when_instantiated_from_storage(): void { $instance = $this->createHolderInstance(); @@ -41,7 +41,7 @@ private function createHolderInstance(): SplitTokenValueHolder return (new Instantiator())->instantiate(SplitTokenValueHolder::class); } - /** @test */ + #[Test] public function its_not_empty_with_data(): void { $instance = new SplitTokenValueHolder(self::SELECTOR, self::VERIFIER); @@ -53,29 +53,38 @@ public function its_not_empty_with_data(): void self::assertFalse(SplitTokenValueHolder::isEmpty($instance)); } - /** @test */ + #[Test] public function it_allows_to_replace_current(): void { self::assertTrue(SplitTokenValueHolder::mayReplaceCurrentToken($this->createHolderInstance())); } - /** @test */ + #[Test] public function it_allows_to_replace_current_token_when_expired(): void { - self::assertTrue(SplitTokenValueHolder::mayReplaceCurrentToken(new SplitTokenValueHolder(self::SELECTOR, self::VERIFIER, new DateTimeImmutable('-100 seconds')))); + self::assertTrue( + SplitTokenValueHolder::mayReplaceCurrentToken( + new SplitTokenValueHolder(self::SELECTOR, self::VERIFIER, new \DateTimeImmutable('-100 seconds')) + ) + ); } - /** @test */ + #[Test] public function it_allows_to_replace_current_token_when_metadata_mismatches(): void { - self::assertTrue(SplitTokenValueHolder::mayReplaceCurrentToken(new SplitTokenValueHolder(self::SELECTOR, self::VERIFIER, null, ['foo' => 'me once']), ['shame' => 'on you'])); + self::assertTrue( + SplitTokenValueHolder::mayReplaceCurrentToken( + new SplitTokenValueHolder(self::SELECTOR, self::VERIFIER, null, ['foo' => 'me once']), + ['shame' => 'on you'] + ) + ); } - /** @test */ + #[Test] public function it_produces_a_new_object_when_changing_metadata(): void { - $current = new SplitTokenValueHolder(self::SELECTOR, self::VERIFIER, new DateTimeImmutable('+10 seconds')); - $second = $current->withMetadata(['foo' => 'me twice']); + $current = new SplitTokenValueHolder(self::SELECTOR, self::VERIFIER, new \DateTimeImmutable('+10 seconds')); + $second = $current->withMetadata(['foo' => 'me twice']); self::assertNotSame($current, $second); self::assertEquals([], $current->metadata()); @@ -85,28 +94,28 @@ public function it_produces_a_new_object_when_changing_metadata(): void self::assertEquals(['foo' => 'me twice'], $second->metadata()); } - /** @test */ + #[Test] public function it_returns_if_expired(): void { $instance = new SplitTokenValueHolder(self::SELECTOR, self::VERIFIER); self::assertFalse($instance->isExpired()); - self::assertFalse($instance->isExpired(new DateTimeImmutable('+10 seconds'))); + self::assertFalse($instance->isExpired(new \DateTimeImmutable('+10 seconds'))); - $instance = new SplitTokenValueHolder(self::SELECTOR, self::VERIFIER, new DateTimeImmutable('+10 seconds')); + $instance = new SplitTokenValueHolder(self::SELECTOR, self::VERIFIER, new \DateTimeImmutable('+10 seconds')); self::assertFalse($instance->isExpired()); - self::assertFalse($instance->isExpired(new DateTimeImmutable('-15 seconds'))); - self::assertTrue($instance->isExpired(new DateTimeImmutable('+12 seconds'))); - self::assertTrue($instance->isExpired(new DateTimeImmutable('+12 seconds'))); + self::assertFalse($instance->isExpired(new \DateTimeImmutable('-15 seconds'))); + self::assertTrue($instance->isExpired(new \DateTimeImmutable('+12 seconds'))); + self::assertTrue($instance->isExpired(new \DateTimeImmutable('+12 seconds'))); } - /** @test */ + #[Test] public function it_equals_other_objects(): void { - $current = new SplitTokenValueHolder(self::SELECTOR, self::VERIFIER); - $second = new SplitTokenValueHolder(self::SELECTOR, self::VERIFIER); - $withExpiration = new SplitTokenValueHolder(self::SELECTOR, self::VERIFIER, new DateTimeImmutable('+5 seconds')); + $current = new SplitTokenValueHolder(self::SELECTOR, self::VERIFIER); + $second = new SplitTokenValueHolder(self::SELECTOR, self::VERIFIER); + $withExpiration = new SplitTokenValueHolder(self::SELECTOR, self::VERIFIER, new \DateTimeImmutable('+5 seconds')); self::assertTrue($current->equals($second)); self::assertTrue($current->equals($current)); From 830a1f96188b66021278e64ffde51e3bc4473d6a Mon Sep 17 00:00:00 2001 From: Sebastiaan Stok Date: Sun, 5 Nov 2023 20:01:15 +0100 Subject: [PATCH 5/9] Allow easier expiration handling Allow a DateInterval or exact DateTime point And allow to specify a Clock instance for easier testing --- composer.json | 6 +- src/AbstractSplitTokenFactory.php | 49 ++++++++++++ src/Argon2SplitToken.php | 5 +- src/Argon2SplitTokenFactory.php | 22 ++---- src/FakeSplitTokenFactory.php | 17 ++--- src/SplitToken.php | 17 +++-- src/SplitTokenFactory.php | 12 ++- tests/Argon2SplitTokenFactoryTest.php | 39 +++++++++- tests/Argon2SplitTokenTest.php | 43 +++++++++-- tests/FakeSplitTokenFactoryTest.php | 103 ++++++++++++++++++++++++++ 10 files changed, 264 insertions(+), 49 deletions(-) create mode 100644 src/AbstractSplitTokenFactory.php create mode 100644 tests/FakeSplitTokenFactoryTest.php diff --git a/composer.json b/composer.json index 2a6ec61..b4b5d10 100644 --- a/composer.json +++ b/composer.json @@ -18,12 +18,14 @@ "require": { "php": ">=8.2", "paragonie/constant_time_encoding": "^2.6", - "paragonie/hidden-string": "^2.0" + "paragonie/hidden-string": "^2.0", + "psr/clock": "^1.0" }, "require-dev": { "doctrine/instantiator": "^2.0", "phpunit/phpunit": "^10.4", - "rollerscapes/standards": "^1.0" + "rollerscapes/standards": "^1.0", + "symfony/clock": "^6.3" }, "minimum-stability": "dev", "prefer-stable": true, diff --git a/src/AbstractSplitTokenFactory.php b/src/AbstractSplitTokenFactory.php new file mode 100644 index 0000000..b019d68 --- /dev/null +++ b/src/AbstractSplitTokenFactory.php @@ -0,0 +1,49 @@ +defaultLifeTime = $defaultLifeTime; + } + + #[Required] + public function setClock(ClockInterface $clock): void + { + $this->clock = $clock; + } + + final protected function getExpirationTimestamp(\DateTimeImmutable | \DateInterval $expiration = null): ?\DateTimeImmutable + { + if ($expiration instanceof \DateTimeImmutable) { + return $expiration; + } + + $expiration ??= $this->defaultLifeTime; + + if ($expiration !== null) { + return (isset($this->clock) ? $this->clock->now() : new \DateTimeImmutable('now'))->add($expiration); + } + + return null; + } +} diff --git a/src/Argon2SplitToken.php b/src/Argon2SplitToken.php index 293259d..6033a8e 100644 --- a/src/Argon2SplitToken.php +++ b/src/Argon2SplitToken.php @@ -34,9 +34,12 @@ protected function verifyHash(string $hash, string $verifier): bool return password_verify($verifier, $hash); } + /** + * @codeCoverageIgnore + */ protected function hashVerifier(string $verifier): string { - $passwordHash = password_hash($verifier, \PASSWORD_ARGON2I, $this->config); + $passwordHash = password_hash($verifier, \PASSWORD_ARGON2ID, $this->config); if ($passwordHash === false || $passwordHash === null) { throw new \RuntimeException('Unrecoverable password hashing error.'); diff --git a/src/Argon2SplitTokenFactory.php b/src/Argon2SplitTokenFactory.php index 39ecc40..0a2ebb1 100644 --- a/src/Argon2SplitTokenFactory.php +++ b/src/Argon2SplitTokenFactory.php @@ -21,31 +21,23 @@ * 'time_cost' amount of time that Argon2lib will spend trying to compute a hash. * 'threads' number of threads that Argon2lib will use. */ -final class Argon2SplitTokenFactory implements SplitTokenFactory +final class Argon2SplitTokenFactory extends AbstractSplitTokenFactory { - private $config; - private $defaultExpirationTimestamp; - - /** @param array{'memory_cost'|'time_cost'|'threads': int} $config */ - public function __construct(array $config = [], \DateTimeImmutable $defaultExpirationTimestamp = null) + /** @param array $config */ + public function __construct(private array $config = [], \DateInterval | string | null $defaultLifeTime = null) { - $this->config = $config; - $this->defaultExpirationTimestamp = $defaultExpirationTimestamp; + parent::__construct($defaultLifeTime); } - public function generate(): SplitToken + public function generate(\DateTimeImmutable | \DateInterval $expiresAt = null): SplitToken { $splitToken = Argon2SplitToken::create( // DO NOT ENCODE HERE (always provide as raw binary)! - new HiddenString(random_bytes((int) SplitToken::TOKEN_CHAR_LENGTH), false, true), + new HiddenString(random_bytes(SplitToken::TOKEN_DATA_LENGTH), false, true), $this->config ); - if ($this->defaultExpirationTimestamp !== null) { - $splitToken->expireAt($this->defaultExpirationTimestamp); - } - - return $splitToken; + return $splitToken->expireAt($this->getExpirationTimestamp($expiresAt)); } public function fromString(string $token): SplitToken diff --git a/src/FakeSplitTokenFactory.php b/src/FakeSplitTokenFactory.php index 3b645f2..a65a074 100644 --- a/src/FakeSplitTokenFactory.php +++ b/src/FakeSplitTokenFactory.php @@ -17,7 +17,7 @@ * * !! THIS IMPLEMENTATION IS NOT SECURE, USE ONLY FOR TESTING !! */ -final class FakeSplitTokenFactory implements SplitTokenFactory +final class FakeSplitTokenFactory extends AbstractSplitTokenFactory { public const SELECTOR = '1zUeXUvr4LKymANBB_bLEqiP5GPr-Pha'; public const VERIFIER = '_OR6OOnV1o8Vy_rWhDoxKNIt'; @@ -25,24 +25,23 @@ final class FakeSplitTokenFactory implements SplitTokenFactory private $randomValue; - public static function instance(string $randomValue = null): self - { - return new self($randomValue); - } - public static function randomInstance(): self { return new self(random_bytes(FakeSplitToken::TOKEN_DATA_LENGTH)); } - public function __construct(string $randomValue = null) + public function __construct(string $randomValue = null, \DateInterval | string | null $defaultLifeTime = null) { + parent::__construct($defaultLifeTime); + $this->randomValue = $randomValue ?? hex2bin('d7351e5d4bebe0b2b298034107f6cb12a88fe463ebf8f85afce47a38e9d5d68f15cbfad6843a3128d22d'); } - public function generate(): SplitToken + public function generate(\DateTimeImmutable | \DateInterval $expiresAt = null): SplitToken { - return FakeSplitToken::create(new HiddenString($this->randomValue, false, true)); + $splitToken = FakeSplitToken::create(new HiddenString($this->randomValue, false, true)); + + return $splitToken->expireAt($this->getExpirationTimestamp($expiresAt)); } public function fromString(string $token): SplitToken diff --git a/src/SplitToken.php b/src/SplitToken.php index 1ab8c12..ba99486 100644 --- a/src/SplitToken.php +++ b/src/SplitToken.php @@ -84,10 +84,11 @@ */ abstract class SplitToken { - public const SELECTOR_BYTES = 24; - public const VERIFIER_BYTES = 18; - public const TOKEN_DATA_LENGTH = (self::VERIFIER_BYTES + self::SELECTOR_BYTES); - public const TOKEN_CHAR_LENGTH = (self::SELECTOR_BYTES * 4 / 3) + (self::VERIFIER_BYTES * 4 / 3); + final public const SELECTOR_BYTES = 24; + final public const VERIFIER_BYTES = 18; + final public const SELECTOR_LENGTH = 32; // Produced by SELECTOR_BYTES base64-encoded + final public const TOKEN_DATA_LENGTH = (self::VERIFIER_BYTES + self::SELECTOR_BYTES); + final public const TOKEN_CHAR_LENGTH = ((self::SELECTOR_BYTES * 4) / 3) + ((self::VERIFIER_BYTES * 4) / 3); /** @var array */ protected array $config = []; @@ -116,7 +117,7 @@ public static function create(HiddenString $randomBytes, array $config = []): st { $bytesString = $randomBytes->getString(); - if (Binary::safeStrlen($bytesString) < self::TOKEN_DATA_LENGTH) { + if (Binary::safeStrlen($bytesString) !== self::TOKEN_DATA_LENGTH) { // Don't zero memory as the value is invalid. throw new \RuntimeException(sprintf('Invalid token-data provided, expected exactly %s bytes.', self::TOKEN_DATA_LENGTH)); } @@ -151,13 +152,13 @@ public function expireAt(\DateTimeImmutable $expiresAt = null): static */ final public static function fromString(string $token): static { - if (Binary::safeStrlen($token) < self::TOKEN_CHAR_LENGTH) { + if (Binary::safeStrlen($token) !== self::TOKEN_CHAR_LENGTH) { // Don't zero memory as the value is invalid. throw new \RuntimeException('Invalid token provided.'); } - $selector = Binary::safeSubstr($token, 0, 32); - $verifier = Binary::safeSubstr($token, 32); + $selector = Binary::safeSubstr($token, 0, self::SELECTOR_LENGTH); + $verifier = Binary::safeSubstr($token, self::SELECTOR_LENGTH); $instance = new static(new HiddenString($token), $selector, $verifier); // Don't generate hash, as the verifier needs the salt of the stored hash. diff --git a/src/SplitTokenFactory.php b/src/SplitTokenFactory.php index 090751b..aedb1c1 100644 --- a/src/SplitTokenFactory.php +++ b/src/SplitTokenFactory.php @@ -10,6 +10,8 @@ namespace Rollerworks\Component\SplitToken; +use ParagonIE\HiddenString\HiddenString; + interface SplitTokenFactory { /** @@ -19,17 +21,19 @@ interface SplitTokenFactory * * ``` * return SplitToken::create( - * new HiddenString(\random_bytes(SplitToken::TOKEN_CHAR_LENGTH), false, true), // DO NOT ENCODE HERE (always provide as raw binary)! + * // DO NOT ENCODE HERE (always provide the random data as raw binary)! + * new HiddenString(\random_bytes(SplitToken::TOKEN_CHAR_LENGTH), false, true), * $id * ); * ``` * - * @see \ParagonIE\Halite\HiddenString + * @see HiddenString */ - public function generate(): SplitToken; + public function generate(\DateTimeImmutable | \DateInterval $expiresAt = null): SplitToken; /** - * Recreates a SplitToken object from a HiddenString (provided by eg. a user). + * Recreates a SplitToken object from a token-string + * (provided by either request attribute). * * Example: * diff --git a/tests/Argon2SplitTokenFactoryTest.php b/tests/Argon2SplitTokenFactoryTest.php index 4c529a9..67fdf64 100644 --- a/tests/Argon2SplitTokenFactoryTest.php +++ b/tests/Argon2SplitTokenFactoryTest.php @@ -13,14 +13,18 @@ use PHPUnit\Framework\Attributes\Test; use PHPUnit\Framework\TestCase; use Rollerworks\Component\SplitToken\Argon2SplitTokenFactory; +use Rollerworks\Component\SplitToken\SplitToken; +use Symfony\Component\Clock\Test\ClockSensitiveTrait; /** * @internal */ final class Argon2SplitTokenFactoryTest extends TestCase { + use ClockSensitiveTrait; + #[Test] - public function it_generates_a_new_token_on_every_call() + public function it_generates_a_new_token_on_every_call(): void { $factory = new Argon2SplitTokenFactory(); $splitToken1 = $factory->generate(); @@ -31,7 +35,38 @@ public function it_generates_a_new_token_on_every_call() } #[Test] - public function it_creates_from_string() + public function it_generates_with_default_expiration(): void + { + $factory = new Argon2SplitTokenFactory(defaultLifeTime: new \DateInterval('P1D')); + $factory->setClock(self::mockTime('2023-10-05T20:00:00+02:00')); + + self::assertExpirationEquals('2023-10-06T20:00:00', $factory->generate()); + self::assertExpirationEquals('2023-10-07T20:00:00', $factory->generate(new \DateInterval('P2D'))); + self::assertExpirationEquals('2019-10-05T20:00:00', $factory->generate(new \DateTimeImmutable('2019-10-05T20:00:00+02:00'))); + + $factory = new Argon2SplitTokenFactory(); + $factory->setClock(self::mockTime('2023-10-05T20:00:00+02:00')); + + self::assertNull($factory->generate()->getExpirationTime()); + self::assertExpirationEquals('2023-10-07T20:00:00', $factory->generate(new \DateInterval('P2D'))); + } + + #[Test] + public function it_generates_with_default_expiration_as_string(): void + { + $factory = new Argon2SplitTokenFactory(defaultLifeTime: 'P1D'); + $factory->setClock(self::mockTime('2023-10-05T20:00:00+02:00')); + + self::assertExpirationEquals('2023-10-06T20:00:00', $factory->generate()); + } + + private static function assertExpirationEquals(string $expected, SplitToken $actual): void + { + self::assertSame($expected, $actual->getExpirationTime()->format('Y-m-d\TH:i:s')); + } + + #[Test] + public function it_creates_from_string(): void { $factory = new Argon2SplitTokenFactory(); $splitToken = $factory->generate(); diff --git a/tests/Argon2SplitTokenTest.php b/tests/Argon2SplitTokenTest.php index 1a5d178..aa0f026 100644 --- a/tests/Argon2SplitTokenTest.php +++ b/tests/Argon2SplitTokenTest.php @@ -33,7 +33,7 @@ public static function createRandomBytes() } #[Test] - public function it_validates_the_correct_length() + public function it_validates_the_correct_length_less() { $this->expectException(\RuntimeException::class); $this->expectExceptionMessage('Invalid token-data provided, expected exactly 42 bytes.'); @@ -41,6 +41,33 @@ public function it_validates_the_correct_length() SplitToken::create(new HiddenString('NanananaBatNan', false, true)); } + #[Test] + public function it_validates_the_correct_length_more() + { + $this->expectException(\RuntimeException::class); + $this->expectExceptionMessage('Invalid token-data provided, expected exactly 42 bytes.'); + + SplitToken::create(new HiddenString('NanananaBatNanNanananaBatNanNanananaBatNanNanananaBatNanNanananaBatNan', false, true)); + } + + #[Test] + public function it_validates_the_correct_length_from_string_less() + { + $this->expectException(\RuntimeException::class); + $this->expectExceptionMessage('Invalid token provided.'); + + SplitToken::fromString('NanananaBatNan'); + } + + #[Test] + public function it_validates_the_correct_length_from_string_more() + { + $this->expectException(\RuntimeException::class); + $this->expectExceptionMessage('Invalid token provided.'); + + SplitToken::fromString('NanananaBatNanNanananaBatNanNanananaBatNanNanananaBatNanNanananaBatNan'); + } + #[Test] public function it_creates_a_split_token_without_id() { @@ -82,7 +109,7 @@ public function it_creates_a_split_token_with_custom_config() } #[Test] - public function it_produces_a__split_token_value_holder() + public function it_produces_a_split_token_value_holder() { $splitToken = SplitToken::create(self::$randValue); @@ -96,7 +123,7 @@ public function it_produces_a__split_token_value_holder() } #[Test] - public function it_produces_a__split_token_value_holder_with_metadata() + public function it_produces_a_split_token_value_holder_with_metadata() { $splitToken = SplitToken::create(self::$randValue); $value = $splitToken->toValueHolder(['he' => 'now']); @@ -106,7 +133,7 @@ public function it_produces_a__split_token_value_holder_with_metadata() } #[Test] - public function it_produces_a__split_token_value_holder_with_expiration() + public function it_produces_a_split_token_value_holder_with_expiration() { $date = new \DateTimeImmutable('+5 minutes'); $splitToken = SplitToken::create($fullToken = self::$randValue)->expireAt($date); @@ -137,7 +164,7 @@ public function it_fails_when_creating_holder_with_string_constructed() } #[Test] - public function it_verifies__split_token() + public function it_verifies_split_token() { // Stored. $splitTokenHolder = SplitToken::create(self::$randValue)->toValueHolder(); @@ -149,7 +176,7 @@ public function it_verifies__split_token() } #[Test] - public function it_verifies__split_token_from_string_and_no_current_token_set() + public function it_verifies_split_token_from_string_and_no_current_token_set() { $fromString = SplitToken::fromString(self::FULL_TOKEN); @@ -157,7 +184,7 @@ public function it_verifies__split_token_from_string_and_no_current_token_set() } #[Test] - public function it_verifies__split_token_from_string_selector() + public function it_verifies_split_token_from_string_selector() { // Stored. $splitTokenHolder = SplitToken::create(self::$randValue)->toValueHolder(); @@ -170,7 +197,7 @@ public function it_verifies__split_token_from_string_selector() } #[Test] - public function it_verifies__split_token_from_string_with_expiration() + public function it_verifies_split_token_from_string_with_expiration() { // Stored. $splitTokenHolder = SplitToken::create(self::$randValue) diff --git a/tests/FakeSplitTokenFactoryTest.php b/tests/FakeSplitTokenFactoryTest.php new file mode 100644 index 0000000..e542368 --- /dev/null +++ b/tests/FakeSplitTokenFactoryTest.php @@ -0,0 +1,103 @@ +generate(); + $splitToken2 = $factory->generate(); + + self::assertEquals($splitToken1->selector(), $splitToken2->selector()); + self::assertEquals($splitToken1, $splitToken2); + + $factory2 = new FakeSplitTokenFactory(); + $splitToken1 = $factory->generate(); + $splitToken2 = $factory2->generate(); + + self::assertEquals($splitToken1->selector(), $splitToken2->selector()); + self::assertEquals($splitToken1, $splitToken2); + } + + #[Test] + public function it_generates_a_new_token_when_passed(): void + { + $splitToken1 = FakeSplitTokenFactory::randomInstance()->generate(); + $splitToken2 = FakeSplitTokenFactory::randomInstance()->generate(); + + self::assertNotEquals($splitToken1->selector(), $splitToken2->selector()); + self::assertNotEquals($splitToken1, $splitToken2); + } + + #[Test] + public function it_generates_with_default_expiration_date(): void + { + $factory = new FakeSplitTokenFactory(defaultLifeTime: new \DateInterval('P1D')); + $factory->setClock(self::mockTime('2023-10-05T20:00:00+02:00')); + + self::assertExpirationEquals('2023-10-06T20:00:00', $factory->generate()); + self::assertExpirationEquals('2023-10-07T20:00:00', $factory->generate(new \DateInterval('P2D'))); + self::assertExpirationEquals('2019-10-05T20:00:00', $factory->generate(new \DateTimeImmutable('2019-10-05T20:00:00+02:00'))); + + $factory = new FakeSplitTokenFactory(); + $factory->setClock(self::mockTime('2023-10-05T20:00:00+02:00')); + + self::assertNull($factory->generate()->getExpirationTime()); + self::assertExpirationEquals('2023-10-07T20:00:00', $factory->generate(new \DateInterval('P2D'))); + } + + private static function assertExpirationEquals(string $expected, SplitToken $actual): void + { + self::assertSame($expected, $actual->getExpirationTime()->format('Y-m-d\TH:i:s')); + } + + #[Test] + public function it_creates_from_string(): void + { + $factory = new FakeSplitTokenFactory(); + $splitToken = $factory->generate(); + + $fullToken = $splitToken->token()->getString(); + $splitTokenFromString = $factory->fromString($fullToken); + + self::assertTrue($splitTokenFromString->matches($splitToken->toValueHolder())); + } + + #[Test] + public function it_creates_from_string_with_mock_provided_selector(): void + { + $factory = new FakeSplitTokenFactory(); + $splitToken = $factory->generate(); + + $fullToken = FakeSplitTokenFactory::FULL_TOKEN; + $fullTokenStr = $splitToken->token()->getString(); + + $splitTokenFromString = $factory->fromString($fullToken); + $splitTokenFromString2 = $factory->fromString($fullTokenStr); + + self::assertEquals($fullTokenStr, $fullToken); + self::assertTrue($splitTokenFromString->matches($splitToken->toValueHolder())); + self::assertTrue($splitTokenFromString2->matches($splitToken->toValueHolder())); + } +} From 79f0ea941ee99722fd8c6e6a80048e369d24850f Mon Sep 17 00:00:00 2001 From: Sebastiaan Stok Date: Sun, 5 Nov 2023 20:33:36 +0100 Subject: [PATCH 6/9] Add upgrade instructions - replaces changelog --- CHANGELOG.md | 8 -------- UPGRADE.md | 17 +++++++++++++++++ src/AbstractSplitTokenFactory.php | 1 + src/Argon2SplitToken.php | 4 +--- src/FakeSplitTokenFactory.php | 7 +++---- src/SplitTokenFactory.php | 5 +++++ tests/FakeSplitTokenFactoryTest.php | 1 - 7 files changed, 27 insertions(+), 16 deletions(-) delete mode 100644 CHANGELOG.md create mode 100644 UPGRADE.md diff --git a/CHANGELOG.md b/CHANGELOG.md deleted file mode 100644 index 0def1da..0000000 --- a/CHANGELOG.md +++ /dev/null @@ -1,8 +0,0 @@ -Change Log -========== - -All notable changes to this publication will be documented in this file. - -## 1.0.0 - ????-??-?? - -First stable release. diff --git a/UPGRADE.md b/UPGRADE.md new file mode 100644 index 0000000..f63d63d --- /dev/null +++ b/UPGRADE.md @@ -0,0 +1,17 @@ +UPGRADE +======= + +## Upgrade from 0.1.2 + +* Support for PHP 8.1 and lower was dropped; + +* Now always uses Aragon2id instead of Aragon2i; + +* The `Argon2SplitTokenFactory` now expects a `DateInterval` or string with a date-interval + as second argument to constructor. Previously this required a `DateTimeImmutable`; + +* The `SplitTokenFactory::generate()` now allows a `DateTimeImmutable` or `DateInterval` + which is calculated relative to "now" or the `now()` as provided by the `ClockInterface`. + + Use `setClock()` on the factory to set an active Clock instance, this is also the recommended + way for using the `FakeSplitTokenFactory()`. diff --git a/src/AbstractSplitTokenFactory.php b/src/AbstractSplitTokenFactory.php index b019d68..db42fd2 100644 --- a/src/AbstractSplitTokenFactory.php +++ b/src/AbstractSplitTokenFactory.php @@ -11,6 +11,7 @@ namespace Rollerworks\Component\SplitToken; use Psr\Clock\ClockInterface; +use Symfony\Contracts\Service\Attribute\Required; abstract class AbstractSplitTokenFactory implements SplitTokenFactory { diff --git a/src/Argon2SplitToken.php b/src/Argon2SplitToken.php index 6033a8e..3131155 100644 --- a/src/Argon2SplitToken.php +++ b/src/Argon2SplitToken.php @@ -34,9 +34,7 @@ protected function verifyHash(string $hash, string $verifier): bool return password_verify($verifier, $hash); } - /** - * @codeCoverageIgnore - */ + /** @codeCoverageIgnore */ protected function hashVerifier(string $verifier): string { $passwordHash = password_hash($verifier, \PASSWORD_ARGON2ID, $this->config); diff --git a/src/FakeSplitTokenFactory.php b/src/FakeSplitTokenFactory.php index a65a074..456702f 100644 --- a/src/FakeSplitTokenFactory.php +++ b/src/FakeSplitTokenFactory.php @@ -30,7 +30,7 @@ public static function randomInstance(): self return new self(random_bytes(FakeSplitToken::TOKEN_DATA_LENGTH)); } - public function __construct(string $randomValue = null, \DateInterval | string | null $defaultLifeTime = null) + public function __construct(string $randomValue = null, \DateInterval | string $defaultLifeTime = null) { parent::__construct($defaultLifeTime); @@ -39,9 +39,8 @@ public function __construct(string $randomValue = null, \DateInterval | string | public function generate(\DateTimeImmutable | \DateInterval $expiresAt = null): SplitToken { - $splitToken = FakeSplitToken::create(new HiddenString($this->randomValue, false, true)); - - return $splitToken->expireAt($this->getExpirationTimestamp($expiresAt)); + return FakeSplitToken::create(new HiddenString($this->randomValue, false, true)) + ->expireAt($this->getExpirationTimestamp($expiresAt)); } public function fromString(string $token): SplitToken diff --git a/src/SplitTokenFactory.php b/src/SplitTokenFactory.php index aedb1c1..e6b7eec 100644 --- a/src/SplitTokenFactory.php +++ b/src/SplitTokenFactory.php @@ -11,9 +11,14 @@ namespace Rollerworks\Component\SplitToken; use ParagonIE\HiddenString\HiddenString; +use Psr\Clock\ClockInterface; +use Symfony\Contracts\Service\Attribute\Required; interface SplitTokenFactory { + #[Required] + public function setClock(ClockInterface $clock): void; + /** * Generates a new SplitToken object. * diff --git a/tests/FakeSplitTokenFactoryTest.php b/tests/FakeSplitTokenFactoryTest.php index e542368..27b730b 100644 --- a/tests/FakeSplitTokenFactoryTest.php +++ b/tests/FakeSplitTokenFactoryTest.php @@ -8,7 +8,6 @@ * file, You can obtain one at http://mozilla.org/MPL/2.0/. */ - use PHPUnit\Framework\Attributes\Test; use PHPUnit\Framework\TestCase; use Rollerworks\Component\SplitToken\FakeSplitTokenFactory; From 8015b98a44bbd67da450df61f6ac8bff6eaf1b8a Mon Sep 17 00:00:00 2001 From: Sebastiaan Stok Date: Mon, 6 Nov 2023 09:28:40 +0100 Subject: [PATCH 7/9] Fix PHPStan errors --- phpstan-baseline.neon | 16 +++++++++ phpstan.neon | 5 +-- src/Argon2SplitToken.php | 8 ++--- src/Argon2SplitTokenFactory.php | 2 +- src/FakeSplitTokenFactory.php | 4 +-- src/SplitToken.php | 6 ++-- src/SplitTokenValueHolder.php | 29 +++++++++++----- tests/Argon2SplitTokenFactoryTest.php | 1 + tests/Argon2SplitTokenTest.php | 49 ++++++++++++++------------- tests/FakeSplitTokenFactoryTest.php | 1 + 10 files changed, 79 insertions(+), 42 deletions(-) create mode 100644 phpstan-baseline.neon diff --git a/phpstan-baseline.neon b/phpstan-baseline.neon new file mode 100644 index 0000000..aca8e15 --- /dev/null +++ b/phpstan-baseline.neon @@ -0,0 +1,16 @@ +parameters: + ignoreErrors: + - + message: "#^Parameter \\#1 \\$hash of method Rollerworks\\\\Component\\\\SplitToken\\\\SplitToken\\:\\:verifyHash\\(\\) expects string, string\\|null given\\.$#" + count: 1 + path: src/SplitToken.php + + - + message: "#^Parameter \\#1 \\$selector of class Rollerworks\\\\Component\\\\SplitToken\\\\SplitTokenValueHolder constructor expects string, string\\|null given\\.$#" + count: 1 + path: src/SplitTokenValueHolder.php + + - + message: "#^Parameter \\#2 \\$verifierHash of class Rollerworks\\\\Component\\\\SplitToken\\\\SplitTokenValueHolder constructor expects string, string\\|null given\\.$#" + count: 1 + path: src/SplitTokenValueHolder.php diff --git a/phpstan.neon b/phpstan.neon index 0e56097..e20dcf1 100644 --- a/phpstan.neon +++ b/phpstan.neon @@ -1,6 +1,6 @@ includes: - vendor/rollerscapes/standards/phpstan.neon - #- phpstan-baseline.neon + - phpstan-baseline.neon parameters: #reportUnmatchedIgnoredErrors: false @@ -13,4 +13,5 @@ parameters: - templates/ - translations/ - #ignoreErrors: + ignoreErrors: + - '#Attribute class Symfony\\Contracts\\Service\\Attribute\\Required does not exist#' # Not required diff --git a/src/Argon2SplitToken.php b/src/Argon2SplitToken.php index 3131155..eb3d49f 100644 --- a/src/Argon2SplitToken.php +++ b/src/Argon2SplitToken.php @@ -37,10 +37,10 @@ protected function verifyHash(string $hash, string $verifier): bool /** @codeCoverageIgnore */ protected function hashVerifier(string $verifier): string { - $passwordHash = password_hash($verifier, \PASSWORD_ARGON2ID, $this->config); - - if ($passwordHash === false || $passwordHash === null) { - throw new \RuntimeException('Unrecoverable password hashing error.'); + try { + $passwordHash = password_hash($verifier, \PASSWORD_ARGON2ID, $this->config); + } catch (\Throwable $e) { + throw new \RuntimeException('Unrecoverable password hashing error.', 0, $e); } return $passwordHash; diff --git a/src/Argon2SplitTokenFactory.php b/src/Argon2SplitTokenFactory.php index 0a2ebb1..ad5f987 100644 --- a/src/Argon2SplitTokenFactory.php +++ b/src/Argon2SplitTokenFactory.php @@ -24,7 +24,7 @@ final class Argon2SplitTokenFactory extends AbstractSplitTokenFactory { /** @param array $config */ - public function __construct(private array $config = [], \DateInterval | string | null $defaultLifeTime = null) + public function __construct(private array $config = [], \DateInterval | string $defaultLifeTime = null) { parent::__construct($defaultLifeTime); } diff --git a/src/FakeSplitTokenFactory.php b/src/FakeSplitTokenFactory.php index 456702f..db3c5e6 100644 --- a/src/FakeSplitTokenFactory.php +++ b/src/FakeSplitTokenFactory.php @@ -23,7 +23,7 @@ final class FakeSplitTokenFactory extends AbstractSplitTokenFactory public const VERIFIER = '_OR6OOnV1o8Vy_rWhDoxKNIt'; public const FULL_TOKEN = self::SELECTOR . self::VERIFIER; - private $randomValue; + private string $randomValue; public static function randomInstance(): self { @@ -34,7 +34,7 @@ public function __construct(string $randomValue = null, \DateInterval | string $ { parent::__construct($defaultLifeTime); - $this->randomValue = $randomValue ?? hex2bin('d7351e5d4bebe0b2b298034107f6cb12a88fe463ebf8f85afce47a38e9d5d68f15cbfad6843a3128d22d'); + $this->randomValue = $randomValue ?? ((string) hex2bin('d7351e5d4bebe0b2b298034107f6cb12a88fe463ebf8f85afce47a38e9d5d68f15cbfad6843a3128d22d')); } public function generate(\DateTimeImmutable | \DateInterval $expiresAt = null): SplitToken diff --git a/src/SplitToken.php b/src/SplitToken.php index ba99486..1a145cf 100644 --- a/src/SplitToken.php +++ b/src/SplitToken.php @@ -98,7 +98,7 @@ abstract class SplitToken private ?string $verifierHash = null; private ?\DateTimeImmutable $expiresAt = null; - private function __construct(HiddenString $token, string $selector, string $verifier) + final private function __construct(HiddenString $token, string $selector, string $verifier) { $this->token = $token; $this->selector = $selector; @@ -189,7 +189,7 @@ public function token(): HiddenString */ final public function matches(?SplitTokenValueHolder $token): bool { - if (SplitTokenValueHolder::isEmpty($token)) { + if ($token === null || SplitTokenValueHolder::isEmpty($token)) { return false; } @@ -235,6 +235,8 @@ public function getExpirationTime(): ?\DateTimeImmutable /** * This method is called in create() before the verifier is hashed, * allowing to set-up configuration for the hashing method. + * + * @param array $config */ protected function configureHasher(array $config): void { diff --git a/src/SplitTokenValueHolder.php b/src/SplitTokenValueHolder.php index fca5a91..7d73380 100644 --- a/src/SplitTokenValueHolder.php +++ b/src/SplitTokenValueHolder.php @@ -27,11 +27,13 @@ */ final class SplitTokenValueHolder { - private $selector; - private $verifierHash; - private $expiresAt; - private $metadata = []; + private ?string $selector = null; + private ?string $verifierHash = null; + private ?\DateTimeImmutable $expiresAt = null; + /** @var array */ + private array $metadata = []; + /** @param array $metadata */ public function __construct(string $selector, string $verifierHash, \DateTimeImmutable $expiresAt = null, array $metadata = []) { $this->selector = $selector; @@ -46,6 +48,8 @@ public static function isEmpty(?self $valueHolder): bool return true; } + // It's possible these values are empty when used as Embedded, because Embedded + // will always produce an object. return $valueHolder->selector === null || $valueHolder->verifierHash === null; } @@ -53,11 +57,13 @@ public static function isEmpty(?self $valueHolder): bool * Returns whether the current token (if any) can be replaced with the new token. * * This methods should only to be used to prevent setting a token when a token - * was already set, which has not expired, and the same metadata was given (type checked!). + * was already set, which has not expired, and the same metadata was given (strict checked!). + * + * @param array $expectedMetadata */ public static function mayReplaceCurrentToken(?self $valueHolder, array $expectedMetadata = []): bool { - if (self::isEmpty($valueHolder)) { + if ($valueHolder === null || self::isEmpty($valueHolder)) { return true; } @@ -78,23 +84,29 @@ public function verifierHash(): ?string return $this->verifierHash; } + /** @param array $metadata */ public function withMetadata(array $metadata): self { + if (self::isEmpty($this)) { + throw new \RuntimeException('Incomplete TokenValueHolder.'); + } + return new self($this->selector, $this->verifierHash, $this->expiresAt, $metadata); } + /** @return array */ public function metadata(): array { return $this->metadata ?? []; } - public function isExpired(\DateTimeImmutable $datetime = null): bool + public function isExpired(\DateTimeImmutable $now = null): bool { if ($this->expiresAt === null) { return false; } - return $this->expiresAt->getTimestamp() < ($datetime ?? new \DateTimeImmutable())->getTimestamp(); + return $this->expiresAt->getTimestamp() < ($now ?? new \DateTimeImmutable())->getTimestamp(); } public function expiresAt(): ?\DateTimeImmutable @@ -106,6 +118,7 @@ public function expiresAt(): ?\DateTimeImmutable * Compares if both objects are the same. * * Warning this method leaks timing information and the expiration date is ignored! + * This method should only be used to check if a new token is provided. */ public function equals(self $other): bool { diff --git a/tests/Argon2SplitTokenFactoryTest.php b/tests/Argon2SplitTokenFactoryTest.php index 67fdf64..217e335 100644 --- a/tests/Argon2SplitTokenFactoryTest.php +++ b/tests/Argon2SplitTokenFactoryTest.php @@ -62,6 +62,7 @@ public function it_generates_with_default_expiration_as_string(): void private static function assertExpirationEquals(string $expected, SplitToken $actual): void { + self::assertNotNull($actual->getExpirationTime()); self::assertSame($expected, $actual->getExpirationTime()->format('Y-m-d\TH:i:s')); } diff --git a/tests/Argon2SplitTokenTest.php b/tests/Argon2SplitTokenTest.php index aa0f026..eb92a92 100644 --- a/tests/Argon2SplitTokenTest.php +++ b/tests/Argon2SplitTokenTest.php @@ -24,16 +24,16 @@ final class Argon2SplitTokenTest extends TestCase private const FULL_TOKEN = '1zUeXUvr4LKymANBB_bLEqiP5GPr-Pha_OR6OOnV1o8Vy_rWhDoxKNIt'; private const SELECTOR = '1zUeXUvr4LKymANBB_bLEqiP5GPr-Pha'; - private static $randValue; + private static HiddenString $randValue; #[BeforeClass] - public static function createRandomBytes() + public static function createRandomBytes(): void { - self::$randValue = new HiddenString(hex2bin('d7351e5d4bebe0b2b298034107f6cb12a88fe463ebf8f85afce47a38e9d5d68f15cbfad6843a3128d22d'), false, true); + self::$randValue = new HiddenString((string) hex2bin('d7351e5d4bebe0b2b298034107f6cb12a88fe463ebf8f85afce47a38e9d5d68f15cbfad6843a3128d22d'), false, true); } #[Test] - public function it_validates_the_correct_length_less() + public function it_validates_the_correct_length_less(): void { $this->expectException(\RuntimeException::class); $this->expectExceptionMessage('Invalid token-data provided, expected exactly 42 bytes.'); @@ -42,7 +42,7 @@ public function it_validates_the_correct_length_less() } #[Test] - public function it_validates_the_correct_length_more() + public function it_validates_the_correct_length_more(): void { $this->expectException(\RuntimeException::class); $this->expectExceptionMessage('Invalid token-data provided, expected exactly 42 bytes.'); @@ -51,7 +51,7 @@ public function it_validates_the_correct_length_more() } #[Test] - public function it_validates_the_correct_length_from_string_less() + public function it_validates_the_correct_length_from_string_less(): void { $this->expectException(\RuntimeException::class); $this->expectExceptionMessage('Invalid token provided.'); @@ -60,7 +60,7 @@ public function it_validates_the_correct_length_from_string_less() } #[Test] - public function it_validates_the_correct_length_from_string_more() + public function it_validates_the_correct_length_from_string_more(): void { $this->expectException(\RuntimeException::class); $this->expectExceptionMessage('Invalid token provided.'); @@ -69,7 +69,7 @@ public function it_validates_the_correct_length_from_string_more() } #[Test] - public function it_creates_a_split_token_without_id() + public function it_creates_a_split_token_without_id(): void { $splitToken = SplitToken::create(self::$randValue); @@ -78,7 +78,7 @@ public function it_creates_a_split_token_without_id() } #[Test] - public function it_creates_a_split_token_with_id() + public function it_creates_a_split_token_with_id(): void { $splitToken = SplitToken::create($fullToken = self::$randValue); @@ -87,7 +87,7 @@ public function it_creates_a_split_token_with_id() } #[Test] - public function it_compares_two_split_tokens() + public function it_compares_two_split_tokens(): void { $splitToken1 = SplitToken::create(self::$randValue); @@ -97,7 +97,7 @@ public function it_compares_two_split_tokens() } #[Test] - public function it_creates_a_split_token_with_custom_config() + public function it_creates_a_split_token_with_custom_config(): void { $splitToken = SplitToken::create(self::$randValue, [ 'memory_cost' => 512, @@ -105,35 +105,38 @@ public function it_creates_a_split_token_with_custom_config() 'threads' => 1, ]); - self::assertMatchesRegularExpression('/^\$argon2[id]+\$v=19\$m=512,t=1,p=1/', $splitToken->toValueHolder()->verifierHash()); + self::assertNotNull($hash = $splitToken->toValueHolder()->verifierHash()); + self::assertMatchesRegularExpression('/^\$argon2id+\$v=19\$m=512,t=1,p=1/', $hash); } #[Test] - public function it_produces_a_split_token_value_holder() + public function it_produces_a_split_token_value_holder(): void { $splitToken = SplitToken::create(self::$randValue); $value = $splitToken->toValueHolder(); self::assertEquals($splitToken->selector(), $value->selector()); - self::assertStringStartsWith('$argon2i', $value->verifierHash()); + self::assertNotNull($hash = $splitToken->toValueHolder()->verifierHash()); + self::assertStringStartsWith('$argon2id', $hash); self::assertEquals([], $value->metadata()); self::assertFalse($value->isExpired()); self::assertFalse($value->isExpired(new \DateTimeImmutable('-5 minutes'))); } #[Test] - public function it_produces_a_split_token_value_holder_with_metadata() + public function it_produces_a_split_token_value_holder_with_metadata(): void { $splitToken = SplitToken::create(self::$randValue); $value = $splitToken->toValueHolder(['he' => 'now']); - self::assertStringStartsWith('$argon2i', $value->verifierHash()); + self::assertNotNull($hash = $splitToken->toValueHolder()->verifierHash()); + self::assertStringStartsWith('$argon2id', $hash); self::assertEquals(['he' => 'now'], $value->metadata()); } #[Test] - public function it_produces_a_split_token_value_holder_with_expiration() + public function it_produces_a_split_token_value_holder_with_expiration(): void { $date = new \DateTimeImmutable('+5 minutes'); $splitToken = SplitToken::create($fullToken = self::$randValue)->expireAt($date); @@ -146,7 +149,7 @@ public function it_produces_a_split_token_value_holder_with_expiration() } #[Test] - public function it_reconstructs_from_string() + public function it_reconstructs_from_string(): void { $splitTokenReconstituted = SplitToken::fromString(self::FULL_TOKEN); @@ -155,7 +158,7 @@ public function it_reconstructs_from_string() } #[Test] - public function it_fails_when_creating_holder_with_string_constructed() + public function it_fails_when_creating_holder_with_string_constructed(): void { $this->expectException(\RuntimeException::class); $this->expectExceptionMessage('toValueHolder() does not work with a SplitToken object when created with fromString().'); @@ -164,7 +167,7 @@ public function it_fails_when_creating_holder_with_string_constructed() } #[Test] - public function it_verifies_split_token() + public function it_verifies_split_token(): void { // Stored. $splitTokenHolder = SplitToken::create(self::$randValue)->toValueHolder(); @@ -176,7 +179,7 @@ public function it_verifies_split_token() } #[Test] - public function it_verifies_split_token_from_string_and_no_current_token_set() + public function it_verifies_split_token_from_string_and_no_current_token_set(): void { $fromString = SplitToken::fromString(self::FULL_TOKEN); @@ -184,7 +187,7 @@ public function it_verifies_split_token_from_string_and_no_current_token_set() } #[Test] - public function it_verifies_split_token_from_string_selector() + public function it_verifies_split_token_from_string_selector(): void { // Stored. $splitTokenHolder = SplitToken::create(self::$randValue)->toValueHolder(); @@ -197,7 +200,7 @@ public function it_verifies_split_token_from_string_selector() } #[Test] - public function it_verifies_split_token_from_string_with_expiration() + public function it_verifies_split_token_from_string_with_expiration(): void { // Stored. $splitTokenHolder = SplitToken::create(self::$randValue) diff --git a/tests/FakeSplitTokenFactoryTest.php b/tests/FakeSplitTokenFactoryTest.php index 27b730b..c8fc6ad 100644 --- a/tests/FakeSplitTokenFactoryTest.php +++ b/tests/FakeSplitTokenFactoryTest.php @@ -68,6 +68,7 @@ public function it_generates_with_default_expiration_date(): void private static function assertExpirationEquals(string $expected, SplitToken $actual): void { + self::assertNotNull($actual->getExpirationTime()); self::assertSame($expected, $actual->getExpirationTime()->format('Y-m-d\TH:i:s')); } From a8a131a166d0c8f302060a09cb59b37ef689b6e4 Mon Sep 17 00:00:00 2001 From: Sebastiaan Stok Date: Mon, 6 Nov 2023 09:29:48 +0100 Subject: [PATCH 8/9] Update GitHub workflows --- .github/workflows/ci.yaml | 189 +++++++++++++++++++++++++++----------- 1 file changed, 134 insertions(+), 55 deletions(-) diff --git a/.github/workflows/ci.yaml b/.github/workflows/ci.yaml index 4938a76..861ea29 100644 --- a/.github/workflows/ci.yaml +++ b/.github/workflows/ci.yaml @@ -1,4 +1,5 @@ -name: Full CI process +name: 'CI' + on: push: branches: @@ -8,71 +9,149 @@ on: - main jobs: - test: - name: PHP ${{ matrix.php-versions }} - runs-on: ubuntu-18.04 + cs-fixer: + name: 'PHP CS Fixer' + + runs-on: 'ubuntu-latest' + strategy: - fail-fast: false matrix: - php-versions: [ '7.2', '7.3', '7.4', '8.0' ] + php-version: + - '8.2' steps: - # —— Setup Github actions 🐙 ————————————————————————————————————————————— - # https://github.com/actions/checkout (official) - - name: Checkout - uses: actions/checkout@v2 + name: 'Check out' + uses: 'actions/checkout@v4' - # https://github.com/shivammathur/setup-php (community) - - name: Setup PHP, extensions and composer with shivammathur/setup-php - uses: shivammathur/setup-php@v2 + name: 'Set up PHP' + uses: 'shivammathur/setup-php@v2' with: - php-version: ${{ matrix.php-versions }} - extensions: mbstring, ctype, iconv, bcmath, filter, json - coverage: none - env: - update: true + php-version: '${{ matrix.php-version }}' + coverage: 'none' + + - + name: 'Get Composer cache directory' + id: 'composer-cache' + run: 'echo "cache_dir=$(composer config cache-files-dir)" >> $GITHUB_OUTPUT' + + - + name: 'Cache dependencies' + uses: 'actions/cache@v3' + with: + path: '${{ steps.composer-cache.outputs.cache_dir }}' + key: "php-${{ matrix.php-version }}-composer-locked-${{ hashFiles('composer.lock') }}" + restore-keys: 'php-${{ matrix.php-version }}-composer-locked-' + + - + name: 'Install dependencies' + run: 'composer install --no-progress' + + - + name: 'Check the code style' + run: 'make cs' + + phpstan: + name: 'PhpStan' + + runs-on: 'ubuntu-latest' + + strategy: + matrix: + php-version: + - '8.2' + + steps: + - + name: 'Check out' + uses: 'actions/checkout@v4' + + - + name: 'Set up PHP' + uses: 'shivammathur/setup-php@v2' + with: + php-version: '${{ matrix.php-version }}' + coverage: 'none' + + - + name: 'Get Composer cache directory' + id: 'composer-cache' + run: 'echo "cache_dir=$(composer config cache-files-dir)" >> $GITHUB_OUTPUT' - # —— Composer 🧙‍️ ————————————————————————————————————————————————————————— - - name: Install Composer dependencies + name: 'Cache dependencies' + uses: 'actions/cache@v3' + with: + path: '${{ steps.composer-cache.outputs.cache_dir }}' + key: "php-${{ matrix.php-version }}-composer-locked-${{ hashFiles('composer.lock') }}" + restore-keys: 'php-${{ matrix.php-version }}-composer-locked-' + + - + name: 'Install dependencies' + run: 'composer install --no-progress' + + - + name: 'Run PhpStan' + run: 'vendor/bin/phpstan analyze --no-progress' + + tests: + name: 'PHPUnit' + + runs-on: 'ubuntu-latest' + + strategy: + matrix: + include: + - + php-version: '8.2' + composer-options: '--prefer-stable' + symfony-version: '6.3' + - + php-version: '8.2' + composer-options: '--prefer-stable' + symfony-version: '^6.4' + + - + php-version: '8.2' + composer-options: '--prefer-stable' + symfony-version: '^7.0' + + steps: + - + name: 'Check out' + uses: 'actions/checkout@v4' + + - + name: 'Set up PHP' + uses: 'shivammathur/setup-php@v2' + with: + php-version: '${{ matrix.php-version }}' + coverage: 'none' + + - + name: 'Get Composer cache directory' + id: 'composer-cache' + run: 'echo "cache_dir=$(composer config cache-files-dir)" >> $GITHUB_OUTPUT' + + - + name: 'Cache dependencies' + uses: 'actions/cache@v3' + with: + path: '${{ steps.composer-cache.outputs.cache_dir }}' + key: "php-${{ matrix.php-version }}-composer-locked-${{ hashFiles('composer.lock') }}" + restore-keys: 'php-${{ matrix.php-version }}-composer-locked-' + + - + name: 'Install dependencies' env: - SYMFONY_PHPUNIT_DISABLE_RESULT_CACHE: 1 + COMPOSER_OPTIONS: '${{ matrix.composer-options }}' + SYMFONY_REQUIRE: '${{ matrix.symfony-version }}' run: | - make install + composer global config --no-plugins allow-plugins.symfony/flex true + composer global require --no-progress --no-scripts --no-plugins symfony/flex + composer update --no-progress $COMPOSER_OPTIONS - ## —— Tests ✅ ——————————————————————————————————————————————————————————— - - name: Run Tests - run: | - make test -# lint: -# name: PHP-QA -# runs-on: ubuntu-latest -# strategy: -# fail-fast: false -# steps: -# - -# name: Checkout -# uses: actions/checkout@v2 -# -# # https://github.com/shivammathur/setup-php (community) -# - -# name: Setup PHP, extensions and composer with shivammathur/setup-php -# uses: shivammathur/setup-php@v2 -# with: -# php-version: '7.4' -# extensions: mbstring, ctype, iconv, bcmath, filter, json -# coverage: none -# -# # —— Composer 🧙‍️ ————————————————————————————————————————————————————————— -# - -# name: Install Composer dependencies -# run: | -# make install -# -# - -# name: Run PHP-QA -# run: | -# make check + name: 'Run tests' + run: make phpunit From 4281795969048d21c846c66786a4b2425b3aeb91 Mon Sep 17 00:00:00 2001 From: Sebastiaan Stok Date: Mon, 6 Nov 2023 09:39:54 +0100 Subject: [PATCH 9/9] Update README.md --- README.md | 16 +++++++++++++++- 1 file changed, 15 insertions(+), 1 deletion(-) diff --git a/README.md b/README.md index 7397144..882efdf 100644 --- a/README.md +++ b/README.md @@ -60,7 +60,20 @@ use Rollerworks\Component\SplitToken\Argon2SplitTokenFactory; // the FakeSplitTokenFactory instead as cryptographic operations // can a little heavy. -$splitTokenFactory = new Argon2SplitTokenFactory(); +// Default configuration, shown here for clarity. +$config = [ + 'memory_cost' => \PASSWORD_ARGON2_DEFAULT_MEMORY_COST, + 'time_cost' => \PASSWORD_ARGON2_DEFAULT_TIME_COST, + 'threads' => \PASSWORD_ARGON2_DEFAULT_THREADS, +]; + +// Either a DateInterval or a DateInterval parsable-string +$defaultLifeTime = null; + +$splitTokenFactory = new Argon2SplitTokenFactory(/*config: $config, */ $defaultLifeTime); + +// Optionally set PSR/Clock compatible instance +// $splitTokenFactory->setClock(); // Step 1. Create a new SplitToken for usage @@ -81,6 +94,7 @@ $authToken = $token->token(); // Returns a \ParagonIE\HiddenString\HiddenString // Indicate when the token must expire. Note that you need to clear the token from storage yourself. // Pass null (or leave this method call absent) to never expire the token (not recommended). // +// If not provided uses "now" + $defaultLifeTime of the factory constructor. $authToken->expireAt(new \DateTimeImmutable('+1 hour')); // Now to store the token cast the SplitToken to a SplitTokenValueHolder object.