diff --git a/.gitattributes b/.gitattributes
index 327c223..93aa015 100644
--- a/.gitattributes
+++ b/.gitattributes
@@ -1,16 +1,14 @@
# Always use LF
core.autocrlf=lf
-/doc export-ignore
-/tests export-ignore
.editorconfig export-ignore
.gitattributes export-ignore
.gitignore export-ignore
-.travis.yml 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
-phpcs.xml.dist export-ignore
phpstan.neon export-ignore
-psalm.xml export-ignore
-Makefile export-ignore
+phpstan-baseline.neon export-ignore
+tests/ export-ignore
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
diff --git a/.gitignore b/.gitignore
index d99db5a..e2245b8 100644
--- a/.gitignore
+++ b/.gitignore
@@ -1,5 +1,9 @@
+composer.lock
/vendor/
-/composer.lock
-/phpcs.xml
-/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/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/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/README.md b/README.md
index dd76e39..882efdf 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
\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
@@ -84,18 +88,19 @@ $token = $splitTokenFactory->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.
// 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.
//
// 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 +112,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 +126,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 +152,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 +183,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
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/composer.json b/composer.json
index a153b77..b4b5d10 100644
--- a/composer.json
+++ b/composer.json
@@ -1,42 +1,34 @@
{
"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",
+ "psr/clock": "^1.0"
},
"require-dev": {
- "doctrine/coding-standard": "^8.0",
- "phpunit/phpunit": "^7.5 || ^8.5",
- "psalm/plugin-phpunit": "^0.13.0"
- },
- "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",
+ "symfony/clock": "^6.3"
},
+ "minimum-stability": "dev",
+ "prefer-stable": true,
"autoload": {
"psr-4": {
"Rollerworks\\Component\\SplitToken\\": "src"
@@ -49,5 +41,13 @@
"psr-4": {
"Rollerworks\\Component\\SplitToken\\Tests\\": "tests"
}
+ },
+ "config": {
+ "sort-packages": true
+ },
+ "extra": {
+ "branch-alias": {
+ "dev-main": "1.0-dev"
+ }
}
}
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/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/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 543d5bb..e20dcf1 100644
--- a/phpstan.neon
+++ b/phpstan.neon
@@ -1,18 +1,17 @@
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:
+ - '#Attribute class Symfony\\Contracts\\Service\\Attribute\\Required does not exist#' # Not required
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
-
+
-
-
+
+
-
+
+
+ vendor/
+ 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 @@
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
diff --git a/src/AbstractSplitTokenFactory.php b/src/AbstractSplitTokenFactory.php
new file mode 100644
index 0000000..db42fd2
--- /dev/null
+++ b/src/AbstractSplitTokenFactory.php
@@ -0,0 +1,50 @@
+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 19e17aa..eb3d49f 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
);
@@ -38,12 +34,13 @@ 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);
-
- if ($passwordHash === false) {
- 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 c1121ce..ad5f987 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):
*
@@ -23,33 +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 int[] $config
- */
- public function __construct(array $config = [], ?DateTimeImmutable $defaultExpirationTimestamp = null)
+ /** @param array $config */
+ public function __construct(private array $config = [], \DateInterval | string $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/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..db3c5e6 100644
--- a/src/FakeSplitTokenFactory.php
+++ b/src/FakeSplitTokenFactory.php
@@ -11,40 +11,36 @@
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.
*
* !! 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';
+ 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
- {
- return new self($randomValue);
- }
+ private string $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 $defaultLifeTime = null)
{
- $this->randomValue = $randomValue ?? hex2bin('d7351e5d4bebe0b2b298034107f6cb12a88fe463ebf8f85afce47a38e9d5d68f15cbfad6843a3128d22d');
+ parent::__construct($defaultLifeTime);
+
+ $this->randomValue = $randomValue ?? ((string) hex2bin('d7351e5d4bebe0b2b298034107f6cb12a88fe463ebf8f85afce47a38e9d5d68f15cbfad6843a3128d22d'));
}
- public function generate(): SplitToken
+ public function generate(\DateTimeImmutable | \DateInterval $expiresAt = null): SplitToken
{
- return FakeSplitToken::create(new HiddenString($this->randomValue, false, true));
+ return FakeSplitToken::create(new HiddenString($this->randomValue, false, true))
+ ->expireAt($this->getExpirationTimestamp($expiresAt));
}
public function fromString(string $token): SplitToken
diff --git a/src/SplitToken.php b/src/SplitToken.php
index d382ddf..1a145cf 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,47 @@
*/
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);
-
- /** @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;
-
- private function __construct(HiddenString $token, string $selector, string $verifier)
+ 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 = [];
+ private HiddenString $token;
+ private string $selector;
+ private string $verifier;
+ private ?string $verifierHash = null;
+ private ?\DateTimeImmutable $expiresAt = null;
+
+ final 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) {
+ 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 +137,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 +149,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) {
+ 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);
+ $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 (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 +169,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;
@@ -208,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;
}
@@ -216,7 +197,6 @@ final public function matches(?SplitTokenValueHolder $token): bool
return false;
}
- /** @psalm-suppress PossiblyNullArgument */
return $this->verifyHash($token->verifierHash(), $this->verifier);
}
@@ -225,12 +205,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 +220,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;
}
@@ -254,8 +235,10 @@ 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)
+ protected function configureHasher(array $config): void
{
// no-op
}
@@ -269,8 +252,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..e6b7eec 100644
--- a/src/SplitTokenFactory.php
+++ b/src/SplitTokenFactory.php
@@ -10,9 +10,15 @@
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.
*
@@ -20,17 +26,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/src/SplitTokenValueHolder.php b/src/SplitTokenValueHolder.php
index e1a4e2d..7d73380 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.
*
@@ -29,17 +27,19 @@
*/
final class SplitTokenValueHolder
{
- private $selector;
- private $verifierHash;
- private $expiresAt;
- private $metadata = [];
-
- public function __construct(string $selector, string $verifierHash, ?DateTimeImmutable $expiresAt = null, array $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;
+ $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
@@ -48,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;
}
@@ -55,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;
}
@@ -80,26 +84,32 @@ 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
+ public function expiresAt(): ?\DateTimeImmutable
{
return $this->expiresAt;
}
@@ -108,11 +118,12 @@ 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
{
- 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..217e335 100644
--- a/tests/Argon2SplitTokenFactoryTest.php
+++ b/tests/Argon2SplitTokenFactoryTest.php
@@ -10,20 +10,23 @@
namespace Rollerworks\Component\SplitToken\Tests;
+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
{
- /**
- * @test
- */
- public function it_generates_a_new_token_on_every_call()
+ use ClockSensitiveTrait;
+
+ #[Test]
+ public function it_generates_a_new_token_on_every_call(): void
{
- $factory = new Argon2SplitTokenFactory();
+ $factory = new Argon2SplitTokenFactory();
$splitToken1 = $factory->generate();
$splitToken2 = $factory->generate();
@@ -31,14 +34,44 @@ public function it_generates_a_new_token_on_every_call()
self::assertNotEquals($splitToken1, $splitToken2);
}
- /**
- * @test
- */
- public function it_creates_from_string()
+ #[Test]
+ 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::assertNotNull($actual->getExpirationTime());
+ 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();
- $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 2904299..eb92a92 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,67 +22,82 @@
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;
+ private static HiddenString $randValue;
- /**
- * @beforeClass
- */
- public static function createRandomBytes()
+ #[BeforeClass]
+ 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()
+ #[Test]
+ public function it_validates_the_correct_length_less(): void
{
- $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
- */
- public function it_creates_a_split_token_without_id()
+ #[Test]
+ public function it_validates_the_correct_length_more(): void
+ {
+ $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(): void
+ {
+ $this->expectException(\RuntimeException::class);
+ $this->expectExceptionMessage('Invalid token provided.');
+
+ SplitToken::fromString('NanananaBatNan');
+ }
+
+ #[Test]
+ public function it_validates_the_correct_length_from_string_more(): void
+ {
+ $this->expectException(\RuntimeException::class);
+ $this->expectExceptionMessage('Invalid token provided.');
+
+ SplitToken::fromString('NanananaBatNanNanananaBatNanNanananaBatNanNanananaBatNanNanananaBatNan');
+ }
+
+ #[Test]
+ public function it_creates_a_split_token_without_id(): void
{
$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
- */
- public function it_creates_a_split_token_with_id()
+ #[Test]
+ public function it_creates_a_split_token_with_id(): void
{
$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
- */
- public function it_compares_two_split_tokens()
+ #[Test]
+ public function it_compares_two_split_tokens(): void
{
$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
- */
- public function it_creates_a_split_token_with_custom_config()
+ #[Test]
+ public function it_creates_a_split_token_with_custom_config(): void
{
$splitToken = SplitToken::create(self::$randValue, [
'memory_cost' => 512,
@@ -91,43 +105,40 @@ 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::assertNotNull($hash = $splitToken->toValueHolder()->verifierHash());
+ self::assertMatchesRegularExpression('/^\$argon2id+\$v=19\$m=512,t=1,p=1/', $hash);
}
- /**
- * @test
- */
- public function it_produces_a_SplitTokenValueHolder()
+ #[Test]
+ 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')));
+ 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(): void
{
$splitToken = SplitToken::create(self::$randValue);
- $value = $splitToken->toValueHolder(['he' => 'now']);
+ $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_SplitTokenValueHolder_with_expiration()
+ #[Test]
+ public function it_produces_a_split_token_value_holder_with_expiration(): void
{
- $date = new DateTimeImmutable('+5 minutes');
+ $date = new \DateTimeImmutable('+5 minutes');
$splitToken = SplitToken::create($fullToken = self::$randValue)->expireAt($date);
$value = $splitToken->toValueHolder();
@@ -137,10 +148,8 @@ public function it_produces_a_SplitTokenValueHolder_with_expiration()
self::assertEquals([], $value->metadata());
}
- /**
- * @test
- */
- public function it_reconstructs_from_string()
+ #[Test]
+ public function it_reconstructs_from_string(): void
{
$splitTokenReconstituted = SplitToken::fromString(self::FULL_TOKEN);
@@ -148,21 +157,17 @@ public function it_reconstructs_from_string()
self::assertEquals(self::SELECTOR, $splitTokenReconstituted->selector());
}
- /**
- * @test
- */
- public function it_fails_when_creating_holder_with_string_constructed()
+ #[Test]
+ public function it_fails_when_creating_holder_with_string_constructed(): void
{
- $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(): void
{
// Stored.
$splitTokenHolder = SplitToken::create(self::$randValue)->toValueHolder();
@@ -173,20 +178,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(): void
{
$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(): void
{
// Stored.
$splitTokenHolder = SplitToken::create(self::$randValue)->toValueHolder();
@@ -198,14 +199,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(): void
{
// Stored.
$splitTokenHolder = SplitToken::create(self::$randValue)
- ->expireAt(new DateTimeImmutable('-5 minutes'))
+ ->expireAt(new \DateTimeImmutable('-5 minutes'))
->toValueHolder();
// Reconstructed.
diff --git a/tests/FakeSplitTokenFactoryTest.php b/tests/FakeSplitTokenFactoryTest.php
new file mode 100644
index 0000000..c8fc6ad
--- /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::assertNotNull($actual->getExpirationTime());
+ 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()));
+ }
+}
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));