diff --git a/.gitattributes b/.gitattributes new file mode 100644 index 0000000..aec5c58 --- /dev/null +++ b/.gitattributes @@ -0,0 +1,20 @@ +/.build export-ignore +/.github export-ignore +/.idea export-ignore +/.phan export-ignore +/.phpdoc export-ignore +/docs export-ignore +/examples export-ignore +/tests export-ignore +/.editorconfig export-ignore +/.gitattributes export-ignore +/.gitignore export-ignore +/.readthedocs.yml export-ignore +/phpcs.xml.dist export-ignore +/phpdoc.xml.dist export-ignore +/phpmd.xml.dist export-ignore +/phpunit.xml.dist export-ignore +/phpstan.dist.neon export-ignore +/phpstan-baseline.neon export-ignore + +*.php diff=php diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 1f2499f..e72c0fa 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -4,13 +4,17 @@ on: push: branches: - - main + - v4.x pull_request: branches: - - main + - v4.x name: "Continuous Integration" +env: + PHP_EXTENSIONS: curl, json, sodium + PHP_INI_VALUES: memory_limit=-1, error_reporting=-1, display_errors=On + jobs: static-code-analysis: @@ -27,28 +31,32 @@ jobs: - "8.1" - "8.2" - "8.3" - - env: - PHAN_ALLOW_XDEBUG: 0 - PHAN_DISABLE_XDEBUG_WARN: 1 + - "8.4" + - "8.5" steps: - name: "Checkout" - uses: actions/checkout@v3 + uses: actions/checkout@v5 - name: "Install PHP" uses: shivammathur/setup-php@v2 with: php-version: ${{ matrix.php-version }} - tools: pecl + extensions: ${{ env.PHP_EXTENSIONS }} + ini-values: ${{ env.PHP_INI_VALUES }} coverage: none - extensions: ast, curl, gmp, json, sodium + + - name: "Validate composer.json" + run: composer validate --ansi --strict - name: "Install dependencies with composer" - uses: ramsey/composer-install@v2 + uses: ramsey/composer-install@v3 + + - name: "Run PHP_CodeSniffer" + run: php vendor/bin/phpcs -v - - name: "Run phan" - run: php vendor/bin/phan +# - name: "Run PHPStan" +# run: php vendor/bin/phpstan tests: @@ -68,27 +76,31 @@ jobs: - "8.1" - "8.2" - "8.3" + - "8.4" + - "8.5" steps: - name: "Checkout" - uses: actions/checkout@v3 + uses: actions/checkout@v5 - name: "Install PHP with extensions" uses: shivammathur/setup-php@v2 with: php-version: ${{ matrix.php-version }} + extensions: ${{ env.PHP_EXTENSIONS }} + ini-values: ${{ env.PHP_INI_VALUES }} coverage: pcov - extensions: curl, gmp, json, sodium - name: "Install dependencies with composer" - uses: ramsey/composer-install@v2 + uses: ramsey/composer-install@v3 - name: "Run tests with phpunit" run: php vendor/bin/phpunit --colors=always --configuration=phpunit.xml.dist - name: "Send code coverage report to Codecov.io" - uses: codecov/codecov-action@v3 + uses: codecov/codecov-action@v5 with: + token: ${{ secrets.CODECOV_TOKEN }} files: .build/coverage/clover.xml - name: "Send code coverage report to Codacy" diff --git a/.gitignore b/.gitignore index d5a788b..371c827 100644 --- a/.gitignore +++ b/.gitignore @@ -6,4 +6,5 @@ phpcs.xml phpdoc.xml phpmd.xml phpunit.xml +phpstan.neon *.phpunit.result.cache diff --git a/.phan/config.php b/.phan/config.php deleted file mode 100644 index 2ad2500..0000000 --- a/.phan/config.php +++ /dev/null @@ -1,58 +0,0 @@ - null, - 'minimum_target_php_version' => '7.4', - - // A list of directories that should be parsed for class and - // method information. After excluding the directories - // defined in exclude_analysis_directory_list, the remaining - // files will be statically analyzed for errors. - // - // Thus, both first-party and third-party code being used by - // your application should be included in this list. - 'directory_list' => [ - 'examples', - 'src', - 'tests', - 'vendor', - ], - - // A regex used to match every file name that you want to - // exclude from parsing. Actual value will exclude every - // "test", "tests", "Test" and "Tests" folders found in - // "vendor/" directory. - 'exclude_file_regex' => '@^vendor/.*/(tests?|Tests?)/@', - - // A directory list that defines files that will be excluded - // from static analysis, but whose class and method - // information should be included. - // - // Generally, you'll want to include the directories for - // third-party code (such as "vendor/") in this list. - // - // n.b.: If you'd like to parse but not analyze 3rd - // party code, directories containing that code - // should be added to both the `directory_list` - // and `exclude_analysis_directory_list` arrays. - 'exclude_analysis_directory_list' => [ - 'vendor/', - ], - 'suppress_issue_types' => [ - 'PhanAccessMethodInternal', - 'PhanDeprecatedFunction', - ], -]; diff --git a/README.md b/README.md index 64a661c..bc21b51 100644 --- a/README.md +++ b/README.md @@ -8,19 +8,18 @@ A generator for counter based ([RFC 4226](https://tools.ietf.org/html/rfc4226)) [![Codacy][codacy-badge]][codacy] [license-badge]: https://img.shields.io/github/license/chillerlan/php-authenticator.svg -[license]: https://github.com/chillerlan/php-authenticator/blob/main/LICENSE -[gh-action-badge]: https://img.shields.io/github/actions/workflow/status/chillerlan/php-authenticator/ci.yml?branch=main&logo=github -[gh-action]: https://github.com/chillerlan/php-authenticator/actions?query=branch%3Amain -[coverage-badge]: https://img.shields.io/codecov/c/gh/chillerlan/php-authenticator/main?logo=codecov -[coverage]: https://app.codecov.io/github/chillerlan/php-authenticator/tree/main -[codacy-badge]: https://img.shields.io/codacy/grade/a2793225b448495c9659f27f7f52380a/main?logo=codacy -[codacy]: https://www.codacy.com/gh/chillerlan/php-authenticator/dashboard?branch=main +[license]: https://github.com/chillerlan/php-authenticator/blob/v4.x/LICENSE +[gh-action-badge]: https://img.shields.io/github/actions/workflow/status/chillerlan/php-authenticator/ci.yml?branch=v4.x&logo=github&logoColor=fff +[gh-action]: https://github.com/chillerlan/php-authenticator/actions?query=branch%3Av4.x +[coverage-badge]: https://img.shields.io/codecov/c/gh/chillerlan/php-authenticator/v4.x?logo=codecov&logoColor=fff +[coverage]: https://app.codecov.io/github/chillerlan/php-authenticator/tree/v4.x +[codacy-badge]: https://img.shields.io/codacy/grade/a2793225b448495c9659f27f7f52380a/v4.x?logo=codacy&logoColor=fff +[codacy]: https://www.codacy.com/gh/chillerlan/php-authenticator/dashboard?branch=v4.x # Documentation ## Requirements - PHP 7.4+ - - [`ext-curl`](https://www.php.net/manual/book.curl) for Battle.net and Steam Guard server time synchronization - - [`ext-gmp`](https://www.php.net/manual/book.gmp) for Battle.net authenticator secret retrieval (RSA encryption) + - [`ext-curl`](https://www.php.net/manual/book.curl) for Steam Guard server time synchronization - [`ext-sodium`](https://www.php.net/manual/book.sodium) for constant time implementations of base64 encode/decode and hex2bin/bin2hex ([`paragonie/constant_time_encoding`](https://github.com/paragonie/constant_time_encoding) is used as fallback) @@ -29,15 +28,16 @@ A generator for counter based ([RFC 4226](https://tools.ietf.org/html/rfc4226)) via terminal: `composer require chillerlan/php-authenticator` -*composer.json* (note: replace `dev-main` with a [version constraint](https://getcomposer.org/doc/articles/versions.md#writing-version-constraints), e.g. `^4.1` - see [releases](https://github.com/chillerlan/php-authenticator/releases) for valid versions) +**composer.json** ```json { "require": { "php": "^7.4 || ^8.0", - "chillerlan/php-authenticator": "dev-main" + "chillerlan/php-authenticator": "dev-v4.x" } } ``` +Note: replace `dev-v4.x` with a [version constraint](https://getcomposer.org/doc/articles/versions.md#writing-version-constraints), e.g. `^4.1` - see [releases](https://github.com/chillerlan/php-authenticator/releases) for valid versions Profit! @@ -45,7 +45,7 @@ Profit! ### Create a secret The secret is usually being created once during the activation process in a user control panel. So all you need to do there is to display it to the user in a convenient way - -as a text string and QR code for example - and save it somewhere with the user data. +as a text string and [QR code](https://github.com/chillerlan/php-qrcode/blob/9964cf8ff1ad90d17c360bd320cf18e16cd59829/examples/authenticator.php) for example - and save it somewhere with the user data. ```php use chillerlan\Authenticator\{Authenticator, AuthenticatorOptions}; @@ -168,7 +168,6 @@ $options->algorithm = AuthenticatorInterface::ALGO_SHA512; | `TOTP` | `string` | | | `HOTP` | `string` | | | `STEAM_GUARD` | `string` | | -| `BATTLE_NET` | `string` | | | `ALGO_SHA1` | `string` | | | `ALGO_SHA256` | `string` | | | `ALGO_SHA512` | `string` | | @@ -177,6 +176,6 @@ $options->algorithm = AuthenticatorInterface::ALGO_SHA512;

- 2FA ALL THE THINGS! + 2FA ALL THE THINGS!

diff --git a/composer.json b/composer.json index 42d31af..685f005 100644 --- a/composer.json +++ b/composer.json @@ -12,6 +12,16 @@ "name": "Smiley", "email": "smiley@chillerlan.net", "homepage": "https://github.com/codemasher" + }, + { + "name": "Contributors", + "homepage":"https://github.com/chillerlan/php-authenticator/graphs/contributors" + } + ], + "funding": [ + { + "type": "Ko-Fi", + "url": "https://ko-fi.com/codemasher" } ], "support": { @@ -22,39 +32,45 @@ "prefer-stable": true, "require": { "php": "^7.4 || ^8.0", - "chillerlan/php-settings-container": "^2.1.4 || ^3.0", + "chillerlan/php-settings-container": "^2.1.6 || ^3.2.1", "paragonie/constant_time_encoding": "^2.6" }, "require-dev": { "ext-curl": "*", - "ext-gmp": "*", "ext-json": "*", "ext-sodium": "*", - "phan/phan": "^5.4", - "phpmd/phpmd": "^2.13", + "phpmd/phpmd": "^2.15", + "phpstan/phpstan": "^2.1.27", + "phpstan/phpstan-deprecation-rules": "^2.0.3", "phpunit/phpunit": "^9.6", - "squizlabs/php_codesniffer": "^3.7" + "slevomat/coding-standard": "^8.23", + "squizlabs/php_codesniffer": "^4.0" }, "suggest": { "chillerlan/php-qrcode": "Create QR Codes for use with an authenticator app." }, "autoload": { "psr-4": { - "chillerlan\\Authenticator\\": "src/" + "chillerlan\\Authenticator\\": "src" } }, "autoload-dev": { "psr-4": { - "chillerlan\\AuthenticatorTest\\": "tests/" + "chillerlan\\AuthenticatorTest\\": "tests" } }, "scripts": { + "phpcs": "@php vendor/bin/phpcs", "phpunit": "@php vendor/bin/phpunit", - "phan": "@php vendor/bin/phan" + "phpstan": "@php vendor/bin/phpstan", + "phpstan-baseline": "@php vendor/bin/phpstan --generate-baseline" }, "config": { "lock": false, "sort-packages": true, - "platform-check": true + "platform-check": true, + "allow-plugins": { + "dealerdirect/phpcodesniffer-composer-installer": true + } } } diff --git a/examples/battlenet.php b/examples/battlenet.php deleted file mode 100644 index 9620d57..0000000 --- a/examples/battlenet.php +++ /dev/null @@ -1,46 +0,0 @@ - - * @copyright 2023 Smiley - * @license MIT - */ - -use chillerlan\Authenticator\{Authenticator, AuthenticatorOptions, Authenticators\BattleNet}; -use chillerlan\Authenticator\Authenticators\AuthenticatorInterface; - -require_once '../vendor/autoload.php'; - -$options = new AuthenticatorOptions([ - // switch mode to BATTLE_NET - 'mode' => AuthenticatorInterface::BATTLE_NET, -]); - -$auth = new Authenticator($options); - -// set a secret - Battle.net secrets come as hex strings (20 byte, 40 chars) -$secret = $auth->setSecret('3132333435363738393031323334353637383930'); -// get a one time code -$code = $auth->code(); -var_dump($code); -// verify the current code -var_dump($auth->verify($code)); // -> true -// previous code -var_dump($auth->verify($code, time() - $options->period)); // -> true -// 2nd adjacent is invalid -var_dump($auth->verify($code, time() + 2 * $options->period)); // -> false -// allow 2 adjacent codes -$options->adjacent = 2; -var_dump($auth->verify($code, time() + 2 * $options->period)); // -> true - -// request a new authenticator from the Battle.net API -// this requires the BattleNet class to be invoked directly as we're using non-interface methods for this -$auth = new BattleNet; -$data = $auth->createAuthenticator('EU'); -// the serial can be used to attach this authenticator to an existing Battle.net account -var_dump($data); -// it's also possible to retreive an authenticator secret from an existing serial and restore code, e.g. from WinAuth -$data = $auth->restoreSecret($data['serial'], $data['restore_code']); -var_dump($data); diff --git a/examples/hotp.php b/examples/hotp.php index 660bcc0..b445005 100644 --- a/examples/hotp.php +++ b/examples/hotp.php @@ -7,6 +7,7 @@ * @copyright 2017 Smiley * @license MIT */ +declare(strict_types=1); use chillerlan\Authenticator\{Authenticator, AuthenticatorOptions}; use chillerlan\Authenticator\Authenticators\AuthenticatorInterface; diff --git a/examples/steam.php b/examples/steam.php index 218e8f2..18e02cf 100644 --- a/examples/steam.php +++ b/examples/steam.php @@ -7,6 +7,7 @@ * @copyright 2017 Smiley * @license MIT */ +declare(strict_types=1); use chillerlan\Authenticator\{Authenticator, AuthenticatorOptions}; use chillerlan\Authenticator\Authenticators\AuthenticatorInterface; @@ -29,9 +30,9 @@ // verify the current code var_dump($auth->verify($code)); // -> true // previous code -var_dump($auth->verify($code, time() - $options->period)); // -> true +var_dump($auth->verify($code, (time() - $options->period))); // -> true // 2nd adjacent is invalid -var_dump($auth->verify($code, time() + 2 * $options->period)); // -> false +var_dump($auth->verify($code, (time() + 2 * $options->period))); // -> false // allow 2 adjacent codes $options->adjacent = 2; -var_dump($auth->verify($code, time() + 2 * $options->period)); // -> true +var_dump($auth->verify($code, (time() + 2 * $options->period))); // -> true diff --git a/examples/totp.php b/examples/totp.php index 706f368..92e16f7 100644 --- a/examples/totp.php +++ b/examples/totp.php @@ -7,6 +7,7 @@ * @copyright 2017 Smiley * @license MIT */ +declare(strict_types=1); use chillerlan\Authenticator\{Authenticator, AuthenticatorOptions}; use chillerlan\Authenticator\Authenticators\AuthenticatorInterface; @@ -38,12 +39,12 @@ // verify the code var_dump($auth->verify($code)); // -> true // verify against the previous time slice -var_dump($auth->verify($code, time() - $options->period)); // -> true +var_dump($auth->verify($code, (time() - $options->period))); // -> true // 2 steps ahead (1 is default) -var_dump($auth->verify($code, time() + 2 * $options->period)); // -> false +var_dump($auth->verify($code, (time() + 2 * $options->period))); // -> false // set adjacent codes to 2 and try again $options->adjacent = 2; -var_dump($auth->verify($code, time() + 2 * $options->period)); // -> true +var_dump($auth->verify($code, (time() + 2 * $options->period))); // -> true // create an URI for use in e.g. QR codes // -> otpauth://totp/test?secret=JQUZJ44H6M3SATXIJRKTK64VQMIU73JN&issuer=example.com&digits=8&algorithm=SHA512&period=60 diff --git a/phpcs.xml.dist b/phpcs.xml.dist index 4e541a4..a69a504 100644 --- a/phpcs.xml.dist +++ b/phpcs.xml.dist @@ -1,43 +1,224 @@ - - php-authenticator rules for phpcs + chillerlan rules for phpcs examples src tests + + + + + + + error + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + examples + + + + + + + examples + + + + - - - - - - - - - - - - - - - - - - - - + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + examples + + examples + benchmark + + + + + @@ -46,10 +227,23 @@ - + + examples + + + + + + + + + + + + @@ -65,124 +259,62 @@ - + - + - + - + + + + + - - - - - - - - - - - - + - + - - - - - - - - - - - - - error - - - - error - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - examples - - - - examples + + + + - - examples + + + + - - examples + + + + - - examples + + + + diff --git a/phpstan-baseline.neon b/phpstan-baseline.neon new file mode 100644 index 0000000..646ebc3 --- /dev/null +++ b/phpstan-baseline.neon @@ -0,0 +1,67 @@ +parameters: + ignoreErrors: + - + rawMessage: ''' + Call to deprecated method getUri() of class chillerlan\Authenticator\Authenticator: + 5.3.0 The parameter `$omitSettings` will be removed in favor of `AuthenticatorOptions::$omitUriSettings` + in the next major version (6.x) + ''' + identifier: method.deprecated + count: 2 + path: examples/hotp.php + + - + rawMessage: ''' + Call to deprecated method getUri() of class chillerlan\Authenticator\Authenticator: + 5.3.0 The parameter `$omitSettings` will be removed in favor of `AuthenticatorOptions::$omitUriSettings` + in the next major version (6.x) + ''' + identifier: method.deprecated + count: 2 + path: examples/totp.php + + - + rawMessage: Cannot access offset 'server_time' on mixed. + identifier: offsetAccess.nonOffsetAccessible + count: 1 + path: src/Authenticators/SteamGuard.php + + - + rawMessage: Cannot cast mixed to int. + identifier: cast.int + count: 1 + path: src/Authenticators/SteamGuard.php + + - + rawMessage: 'Parameter #1 $json of function json_decode expects string, string|true given.' + identifier: argument.type + count: 1 + path: src/Authenticators/SteamGuard.php + + - + rawMessage: 'Parameter #1 $counter of method chillerlan\Authenticator\Authenticators\HOTP::getHMAC() expects int, float|int given.' + identifier: argument.type + count: 1 + path: src/Authenticators/TOTP.php + + - + rawMessage: ''' + Call to deprecated method getUri() of class chillerlan\Authenticator\Authenticator: + 5.3.0 The parameter `$omitSettings` will be removed in favor of `AuthenticatorOptions::$omitUriSettings` + in the next major version (6.x) + ''' + identifier: method.deprecated + count: 7 + path: tests/AuthenticatorTest.php + + - + rawMessage: 'Call to function is_int() with 59|1111111109|1111111111|1234567890|2000000000|20000000000 will always evaluate to true.' + identifier: function.alreadyNarrowedType + count: 1 + path: tests/Authenticators/SteamGuardTest.php + + - + rawMessage: 'Call to function is_int() with 59|1111111109|1111111111|1234567890|2000000000|20000000000 will always evaluate to true.' + identifier: function.alreadyNarrowedType + count: 1 + path: tests/Authenticators/TOTPTest.php diff --git a/phpstan.dist.neon b/phpstan.dist.neon new file mode 100644 index 0000000..06bbc19 --- /dev/null +++ b/phpstan.dist.neon @@ -0,0 +1,17 @@ +# https://phpstan.org/config-reference + +parameters: + level: 9 + tmpDir: .build/phpstan-cache + paths: + - examples + - src + - tests + + treatPhpDocTypesAsCertain: false + +includes: + - phpstan-baseline.neon + - vendor/phpstan/phpstan/conf/bleedingEdge.neon + - vendor/phpstan/phpstan-deprecation-rules/rules.neon + - vendor/chillerlan/php-settings-container/rules-magic-access.neon diff --git a/phpunit.xml.dist b/phpunit.xml.dist index 4038465..d53aeab 100644 --- a/phpunit.xml.dist +++ b/phpunit.xml.dist @@ -1,6 +1,6 @@ - ./tests + tests - ./src + src diff --git a/src/Authenticator.php b/src/Authenticator.php index bacd811..7008fdf 100644 --- a/src/Authenticator.php +++ b/src/Authenticator.php @@ -7,17 +7,12 @@ * @copyright 2015 Smiley * @license MIT */ +declare(strict_types=1); namespace chillerlan\Authenticator; use chillerlan\Authenticator\Authenticators\AuthenticatorInterface; use chillerlan\Settings\SettingsContainerInterface; -use InvalidArgumentException; -use function http_build_query; -use function rawurlencode; -use function sprintf; -use function trim; -use const PHP_QUERY_RFC3986; /** * Yet another Google authenticator implementation! @@ -33,12 +28,11 @@ class Authenticator{ /** @var \chillerlan\Settings\SettingsContainerInterface|\chillerlan\Authenticator\AuthenticatorOptions */ protected SettingsContainerInterface $options; protected AuthenticatorInterface $authenticator; - protected string $mode = AuthenticatorInterface::TOTP; /** * Authenticator constructor */ - public function __construct(SettingsContainerInterface $options = null, string $secret = null){ + public function __construct(?SettingsContainerInterface $options = null, ?string $secret = null){ // phpcs:ignore $this->setOptions($options ?? new AuthenticatorOptions); @@ -58,10 +52,9 @@ public function setOptions(SettingsContainerInterface $options):self{ $this->options = $options; // invoke a new authenticator interface if necessary - if(!isset($this->authenticator) || $this->options->mode !== $this->mode){ + if(!isset($this->authenticator) || $this->authenticator::MODE !== $this->options->mode){ $class = AuthenticatorInterface::MODES[$this->options->mode]; - $this->mode = $this->options->mode; - $this->authenticator = new $class; + $this->authenticator = new $class($this->options); } $this->authenticator->setOptions($this->options); @@ -94,7 +87,7 @@ public function getSecret():string{ * * @codeCoverageIgnore */ - public function createSecret(int $length = null):string{ + public function createSecret(?int $length = null):string{ return $this->authenticator->createSecret($length); } @@ -107,7 +100,7 @@ public function createSecret(int $length = null):string{ * * @codeCoverageIgnore */ - public function code(int $data = null):string{ + public function code(?int $data = null):string{ return $this->authenticator->code($data); } @@ -120,7 +113,7 @@ public function code(int $data = null):string{ * * @codeCoverageIgnore */ - public function verify(string $otp, int $data = null):bool{ + public function verify(string $otp, ?int $data = null):bool{ return $this->authenticator->verify($otp, $data); } @@ -129,37 +122,19 @@ public function verify(string $otp, int $data = null):bool{ * * @link https://github.com/google/google-authenticator/wiki/Key-Uri-Format#parameters * - * @throws \InvalidArgumentException + * @deprecated 5.3.0 The parameter `$omitSettings` will be removed in favor of `AuthenticatorOptions::$omitUriSettings` + * in the next major version (6.x) + * @see \chillerlan\Authenticator\AuthenticatorOptionsTrait::$omitUriSettings + * + * @codeCoverageIgnore */ - public function getUri(string $label, string $issuer, int $hotpCounter = null, bool $omitSettings = null):string{ - $label = trim($label); - $issuer = trim($issuer); - - if(empty($label) || empty($issuer)){ - throw new InvalidArgumentException('$label and $issuer cannot be empty'); + public function getUri(string $label, string $issuer, ?int $hotpCounter = null, ?bool $omitSettings = null):string{ + // a little reckless but good enough until the deprecated parameter is removed + if($omitSettings !== null){ + $this->options->omitUriSettings = $omitSettings; } - $values = [ - 'secret' => $this->authenticator->getSecret(), - 'issuer' => $issuer, - ]; - - if($omitSettings !== true){ - $values['digits'] = $this->options->digits; - $values['algorithm'] = $this->options->algorithm; - - if($this->mode === AuthenticatorInterface::TOTP){ - $values['period'] = $this->options->period; - } - - if($this->mode === AuthenticatorInterface::HOTP && $hotpCounter !== null){ - $values['counter'] = $hotpCounter; - } - } - - $values = http_build_query($values, '', '&', PHP_QUERY_RFC3986); - - return sprintf('otpauth://%s/%s?%s', $this->mode, rawurlencode($label), $values); + return $this->authenticator->getUri($label, $issuer, $hotpCounter); } } diff --git a/src/AuthenticatorOptions.php b/src/AuthenticatorOptions.php index 088a80a..d31e07c 100644 --- a/src/AuthenticatorOptions.php +++ b/src/AuthenticatorOptions.php @@ -7,14 +7,12 @@ * @copyright 2019 smiley * @license MIT */ +declare(strict_types=1); namespace chillerlan\Authenticator; use chillerlan\Settings\SettingsContainerAbstract; -/** - * - */ class AuthenticatorOptions extends SettingsContainerAbstract{ use AuthenticatorOptionsTrait; } diff --git a/src/AuthenticatorOptionsTrait.php b/src/AuthenticatorOptionsTrait.php index b3e75a6..8477e11 100644 --- a/src/AuthenticatorOptionsTrait.php +++ b/src/AuthenticatorOptionsTrait.php @@ -7,6 +7,7 @@ * @copyright 2019 smiley * @license MIT */ +declare(strict_types=1); namespace chillerlan\Authenticator; @@ -16,6 +17,18 @@ use function strtolower; use function strtoupper; +/** + * @property int $digits + * @property int $period + * @property int $secret_length + * @property string $algorithm + * @property string $mode + * @property int $adjacent + * @property int $time_offset + * @property bool $useLocalTime + * @property bool $forceTimeRefresh + * @property bool $omitUriSettings + */ trait AuthenticatorOptionsTrait{ /** @@ -79,6 +92,13 @@ trait AuthenticatorOptionsTrait{ */ protected bool $forceTimeRefresh = false; + /** + * Whether to omit the additional settings in the URI for an authenticator app (algo, digits, period) + * + * @link https://github.com/google/google-authenticator/wiki/Key-Uri-Format#parameters + */ + protected bool $omitUriSettings = false; + /** * Sets the code length to either 6 or 8 * @@ -143,9 +163,9 @@ protected function set_mode(string $mode):void{ * @throws \InvalidArgumentException */ protected function set_adjacent(int $adjacent):void{ - - if($adjacent < 0){ - throw new InvalidArgumentException('Invalid adjacent: '.$adjacent); + // limit to a sane amount + if($adjacent < 0 || $adjacent > 10){ + throw new InvalidArgumentException('Invalid number of adjacent codes: '.$adjacent); } $this->adjacent = $adjacent; @@ -155,7 +175,7 @@ protected function set_adjacent(int $adjacent):void{ * @throws \InvalidArgumentException */ protected function set_secret_length(int $secret_length):void{ - // ~ 80 to 640 bits + if($secret_length < 16 || $secret_length > 1024){ throw new InvalidArgumentException('Invalid secret length: '.$secret_length); } diff --git a/src/Authenticators/AuthenticatorAbstract.php b/src/Authenticators/AuthenticatorAbstract.php index d311a71..12fcce9 100644 --- a/src/Authenticators/AuthenticatorAbstract.php +++ b/src/Authenticators/AuthenticatorAbstract.php @@ -7,6 +7,7 @@ * @copyright 2023 smiley * @license MIT */ +declare(strict_types=1); namespace chillerlan\Authenticator\Authenticators; @@ -15,13 +16,14 @@ use chillerlan\Settings\SettingsContainerInterface; use InvalidArgumentException; use RuntimeException; +use function http_build_query; use function random_bytes; +use function rawurlencode; +use function sprintf; use function time; use function trim; +use const PHP_QUERY_RFC3986; -/** - * - */ abstract class AuthenticatorAbstract implements AuthenticatorInterface{ protected const userAgent = 'chillerlanAuthenticator/5.0 +https://github.com/chillerlan/php-authenticator'; @@ -35,32 +37,23 @@ abstract class AuthenticatorAbstract implements AuthenticatorInterface{ /** * AuthenticatorInterface constructor */ - public function __construct(SettingsContainerInterface $options = null){ + public function __construct(?SettingsContainerInterface $options = null){ // phpcs:ignore $this->setOptions($options ?? new AuthenticatorOptions); } - /** - * @inheritDoc - */ public function setOptions(SettingsContainerInterface $options):AuthenticatorInterface{ $this->options = $options; return $this; } - /** - * @inheritDoc - */ public function setSecret(string $encodedSecret):AuthenticatorInterface{ $this->secret = Base32::decode($this->checkEncodedSecret($encodedSecret)); return $this; } - /** - * @inheritDoc - */ public function getSecret():string{ if($this->secret === null){ @@ -70,10 +63,7 @@ public function getSecret():string{ return Base32::encode($this->secret); } - /** - * @inheritDoc - */ - public function createSecret(int $length = null):string{ + public function createSecret(?int $length = null):string{ $length ??= $this->options->secret_length; if($length < 16){ @@ -85,9 +75,6 @@ public function createSecret(int $length = null):string{ return $this->getSecret(); } - /** - * @inheritDoc - */ public function getServertime():int{ return time(); } @@ -116,4 +103,27 @@ protected function checkEncodedSecret(string $encodedSecret):string{ return $encodedSecret; } + /** + * Returns an array with settings for a mobile authenticator URI for the current authenticator mode/instance + * + * @return array + */ + abstract protected function getUriParams(string $issuer, ?int $counter = null):array; + + public function getUri(string $label, string $issuer, ?int $counter = null):string{ + $label = trim($label); + $issuer = trim($issuer); + + if($label === '' || $issuer === ''){ + throw new InvalidArgumentException('$label and $issuer cannot be empty'); + } + + return sprintf( + 'otpauth://%s/%s?%s', + $this::MODE, + rawurlencode($label), + http_build_query($this->getUriParams($issuer, $counter), '', '&', PHP_QUERY_RFC3986), + ); + } + } diff --git a/src/Authenticators/AuthenticatorInterface.php b/src/Authenticators/AuthenticatorInterface.php index 4bbdd00..aa8645d 100644 --- a/src/Authenticators/AuthenticatorInterface.php +++ b/src/Authenticators/AuthenticatorInterface.php @@ -7,20 +7,17 @@ * @copyright 2023 smiley * @license MIT */ +declare(strict_types=1); namespace chillerlan\Authenticator\Authenticators; use chillerlan\Settings\SettingsContainerInterface; -/** - * - */ interface AuthenticatorInterface{ public const TOTP = 'totp'; public const HOTP = 'hotp'; public const STEAM_GUARD = 'steam'; - public const BATTLE_NET = 'battlenet'; public const ALGO_SHA1 = 'SHA1'; public const ALGO_SHA256 = 'SHA256'; @@ -30,7 +27,6 @@ interface AuthenticatorInterface{ self::HOTP => HOTP::class, self::TOTP => TOTP::class, self::STEAM_GUARD => SteamGuard::class, - self::BATTLE_NET => BattleNet::class, ]; public const HASH_ALGOS = [ @@ -39,6 +35,13 @@ interface AuthenticatorInterface{ self::ALGO_SHA512, ]; + /** + * Mode identifier. Do not call this constant from the interface. but rather from an authenticator instance. + * + * @var string + */ + public const MODE = ''; + /** * Sets the options */ @@ -63,7 +66,7 @@ public function getSecret():string; * * @throws \InvalidArgumentException */ - public function createSecret(int $length = null):string; + public function createSecret(?int $length = null):string; /** * Returns the current server time as UNIX timestamp for the given application (or `time()` if not applicable) @@ -75,7 +78,7 @@ public function getServertime():int; * * @internal */ - public function getCounter(int $data = null):int; + public function getCounter(?int $data = null):int; /** * HMAC hashes the given $data integer with the given secret @@ -105,7 +108,7 @@ public function getOTP(int $code):string; * - a UNIX timestamp (TOTP) * - a counter value (HOTP) */ - public function code(int $data = null):string; + public function code(?int $data = null):string; /** * Checks the given $code against the secret @@ -114,6 +117,15 @@ public function code(int $data = null):string; * - a UNIX timestamp (TOTP) * - a counter value (HOTP) */ - public function verify(string $otp, int $data = null):bool; + public function verify(string $otp, ?int $data = null):bool; + + /** + * Creates a URI string for a mobile authenticator (otpauth://...) + * + * The parameter `$counter` only has effect in HOTP mode + * + * @link https://github.com/google/google-authenticator/wiki/Key-Uri-Format#parameters + */ + public function getUri(string $label, string $issuer, ?int $counter = null):string; } diff --git a/src/Authenticators/BattleNet.php b/src/Authenticators/BattleNet.php deleted file mode 100644 index 4afd89c..0000000 --- a/src/Authenticators/BattleNet.php +++ /dev/null @@ -1,425 +0,0 @@ - - * @copyright 2023 smiley - * @license MIT - * - * @noinspection PhpComposerExtensionStubsInspection - */ - -namespace chillerlan\Authenticator\Authenticators; - -use chillerlan\Authenticator\Common\Hex; -use InvalidArgumentException; -use RuntimeException; -use function array_reverse; -use function array_unshift; -use function curl_close; -use function curl_exec; -use function curl_getinfo; -use function curl_init; -use function curl_setopt_array; -use function floor; -use function gmp_cmp; -use function gmp_div; -use function gmp_import; -use function gmp_init; -use function gmp_intval; -use function gmp_mod; -use function gmp_powm; -use function hash_hmac; -use function hexdec; -use function implode; -use function in_array; -use function pack; -use function preg_match; -use function random_bytes; -use function sha1; -use function sprintf; -use function str_pad; -use function str_replace; -use function str_split; -use function strlen; -use function strtoupper; -use function substr; -use function time; -use function trim; -use function unpack; -use const CURLOPT_HTTP_VERSION; -use const CURLOPT_HTTPHEADER; -use const CURLOPT_POST; -use const CURLOPT_POSTFIELDS; -use const CURLOPT_RETURNTRANSFER; -use const STR_PAD_LEFT; - -/** - * @see https://github.com/winauth/winauth/blob/master/Authenticator/BattleNetAuthenticator.cs - * @see https://github.com/krtek4/php-bma - */ -final class BattleNet extends TOTP{ - - /** - * @var array - */ - private const regions = ['EU', 'KR', 'US']; // 'CN', - - /** - * HTTPS requests with HTTP version 1.1 only! - * - * @var array - */ - private const servers = [ -# 'CN' => 'https://mobile-service.battlenet.com.cn', // ??? - 'EU' => 'https://eu.mobile-service.blizzard.com', - 'KR' => 'https://kr.mobile-service.blizzard.com', - 'US' => 'https://us.mobile-service.blizzard.com', - ]; - - /** - * @var array - */ - private const endpoints = [ - 'public_key' => '/enrollment/initiatePaperRestore.htm', - 'validate' => '/enrollment/validatePaperRestore.htm', - 'create' => '/enrollment/enroll.htm', - 'servertime' => '/enrollment/time.htm', - ]; - - private const rsa_exp_base10 = '257'; - private const rsa_mod_base10 = '1048900188079865568740077109142054431570301596680341971861256789'. - '6028747089429083053061828494311840511089632283544909943323209315'. - '1168250152146023319326491587651685252774820340995950744075665455'. - '6817606521365764930287339148921667008991098362911808810630974611'. - '75643998356321993663868233366705340758102567742483097'; - -# private const rsa_exp_base16 = '0101'; -# private const rsa_mod_base16 = '955e4bd989f3917d2f15544a7e0504eb9d7bb66b6f8a2fe470e453c779200e5e'. -# '3ad2e43a02d06c4adbd8d328f1a426b83658e88bfd949b2af4eaf30054673a14'. -# '19a250fa4cc1278d12855b5b25818d162c6e6ee2ab4a350d401d78f6ddb99711'. -# 'e72626b48bd8b5b0b7f3acf9ea3c9e0005fee59e19136cdb7c83f2ab8b0a2a99'; - - private array $curlInfo = []; - - /** - * @inheritDoc - */ - public function setSecret(string $encodedSecret):AuthenticatorInterface{ - $this->secret = Hex::decode($this->checkEncodedSecret($encodedSecret)); - - return $this; - } - - /** - * @inheritDoc - */ - public function getSecret():string{ - - if($this->secret === null){ - throw new RuntimeException('No secret set'); - } - - return Hex::encode($this->secret); - } - - /** - * @inheritDoc - * @codeCoverageIgnore - */ - public function createSecret(int $length = null):string{ - throw new RuntimeException('Not implemented'); - } - - /** - * @inheritDoc - */ - public function getCounter(int $data = null):int{ - // the period is fixed to 30 seconds for Battle.net - $this->options->period = 30; - - return parent::getCounter($data); - } - - /** - * @inheritDoc - */ - public function getHMAC(int $counter):string{ - // algorithm is fixed to sha1 for Battle.net - $this->options->algorithm = self::ALGO_SHA1; - - return parent::getHMAC($counter); - } - - /** - * @inheritDoc - */ - public function getOTP(int $code):string{ - $code %= 100000000; - - // length is fixed to 8 for Battle.net - return str_pad((string)$code, 8, '0', STR_PAD_LEFT); - } - - /** - * @inheritDoc - */ - public function getServerTime():int{ - - if($this->options->forceTimeRefresh === false && $this->serverTime !== 0){ - return $this->getAdjustedTime($this->serverTime, $this->lastRequestTime); - } - - $servertime = $this->request('servertime', 'US'); - - $this->setServertime($servertime); - - return $this->getAdjustedTime($this->serverTime, $this->lastRequestTime); - } - - /** - * Retrieves the secret from Battle.net using the given serial and restore code. - * If the public key for the serial is given (from a previous retrieval), it saves a server request. - */ - public function restoreSecret(string $serial, string $restore_code, string $public_key = null):array{ - $serial = $this->cleanSerial($serial); - $region = $this->getRegion($serial); - - // fetch public key if none is given - $pubkey = ($public_key !== null) - ? Hex::decode($public_key) - : $this->request('public_key', $region, $serial); - - // create HMAC hash from serial and restore code - $hmac_key = $this->convertRestoreCodeToByte($restore_code); - $hmac = hash_hmac('sha1', $serial.$pubkey, $hmac_key, true); - // encrypt and send validation request - $nonce = random_bytes(20); - $encrypted_secret = $this->request('validate', $region, $serial.$this->encrypt($hmac.$nonce)); - $secret = $this->decrypt($encrypted_secret, $nonce); - - return [ - 'region' => $region, - 'serial' => $this->formatSerial($serial), - 'restore_code' => $restore_code, - 'public_key' => Hex::encode($pubkey), - 'secret' => Hex::encode($secret), - ]; - } - - /** - * Creates a new authenticator that can be linked to an existing Battle.net account - */ - public function createAuthenticator(string $region, string $device = null):array{ - $region = $this->getRegion($region); - $device = str_pad(($device ?? 'BlackBerry Pearl'), 16, "\x00"); - $nonce = random_bytes(37); - $response = $this->request('create', $region, $this->encrypt("\x01".$nonce.$region.$device)); - // timestamp, first 8 bytes of the response - $this->setServertime(substr($response, 0, 8)); - // decrypt rest of the response (37 bytes) - $data = $this->decrypt(substr($response, 8), $nonce); - // secret, first 20 bytes - $secret = substr($data, 0, 20); - // serial, last 17 bytes - $serial = $this->cleanSerial(substr($data, 20)); - // the restore code is taken from the last 10 bytes of a SHA1 hashed serial and (binary) secret - $restore_code = substr(sha1($serial.$secret, true), -10); - - // feed the result into the restore function to verify the restore code and fetch the public key - return $this->restoreSecret($serial, $this->convertRestoreCodeToChar($restore_code)); - } - - /** - * - */ - private function setServertime(string $encodedTimestamp):void{ - $this->serverTime = (int)floor(hexdec(Hex::encode($encodedTimestamp)) / 1000); - $this->lastRequestTime = (time() - (int)floor($this->curlInfo['total_time'])); - } - - /** - * @throws \RuntimeException - */ - private function getRegion(string $serial):string{ - $region = substr(strtoupper($serial), 0, 2); - - if(!in_array($region, self::regions)){ - throw new RuntimeException('invalid region in serial number detected'); - } - - return $region; - } - - /** - * cleans the given serial in (EU-1111-2222-3333) and strips hyphens (EU111122223333) for use in API requests - * - * @throws \InvalidArgumentException - */ - private function cleanSerial(string $serial):string{ - $serial = str_replace('-', '', strtoupper(trim($serial))); - - if(!preg_match('/^[CNEUSKR]{2}\d{12}$/', $serial)){ - throw new InvalidArgumentException('invalid serial'); - } - - return $serial; - } - - /** - * - */ - private function formatSerial(string $serial):string{ - $serial = $this->cleanSerial($serial); - // split the numeric part into 3x 4 numbers - $blocks = str_split(substr($serial, 2), 4); - // prepend the region - array_unshift($blocks, substr($serial, 0, 2)); - - return implode('-', $blocks); - } - - /** - * @throws \RuntimeException - */ - private function request(string $endpoint, string $region, string $data = null):string{ - - $options = [ - CURLOPT_RETURNTRANSFER => true, - CURLOPT_HTTP_VERSION => '1.1', // we need to force http 1.1, h2 will return a HTTP/600 error (???) from Battle.net - CURLOPT_HTTPHEADER => [sprintf('User-Agent: %s', $this::userAgent)], - ]; - - if($data !== null){ - $options[CURLOPT_POST] = true; - $options[CURLOPT_POSTFIELDS] = $data; - $options[CURLOPT_HTTPHEADER][] = 'Content-Type: application/octet-stream'; - } - - $ch = curl_init(self::servers[$region].self::endpoints[$endpoint]); - - curl_setopt_array($ch, $options); - - $response = curl_exec($ch); - $this->curlInfo = curl_getinfo($ch); - - curl_close($ch); - - if($this->curlInfo['http_code'] !== 200){ - // I'm not going to investigate the error further as this shouldn't happen usually - throw new RuntimeException(sprintf('Battle.net API request error: HTTP/%s', $this->curlInfo['http_code'])); // @codeCoverageIgnore - } - - return $response; - } - - /** - * Convert restore code char to byte but with appropriate mapping to exclude I,L,O and S. - * e.g. A=10 but J=18 not 19 (as I is missing) - */ - private function convertRestoreCodeToByte(string $restore_code):string{ - $chars = unpack('C*', $restore_code); - - foreach($chars as &$c){ - if($c > 47 && $c < 58){ - $c -= 48; - } - else{ - // S - if($c > 82){ - $c--; - } - // O - if($c > 78){ - $c--; - } - // L - if($c > 75){ - $c--; - } - // I - if($c > 72){ - $c--; - } - - $c -= 55; - } - - } - - return pack('C*', ...$chars); - } - - /** - * Convert restore code byte to char but with appropriate mapping to exclude I,L,O and S. - */ - private function convertRestoreCodeToChar(string $data):string{ - $chars = unpack('C*', $data); - - foreach($chars as &$c){ - $c &= 0x1F; - - if($c < 10){ - $c += 48; - } - else{ - $c += 55; - // I - if($c > 72){ - $c++; - } - // L - if($c > 75){ - $c++; - } - // O - if($c > 78){ - $c++; - } - // S - if($c > 82){ - $c++; - } - } - } - - return pack('C*', ...$chars); - } - - /** - * - */ - private function encrypt(string $data):string{ - $num = gmp_powm(gmp_import($data), self::rsa_exp_base10, self::rsa_mod_base10); // gmp_init(self::rsa_mod_base16, 16) - $zero = gmp_init('0', 10); - $ret = []; - - while(gmp_cmp($num, $zero) > 0){ - $ret[] = gmp_intval(gmp_mod($num, 256)); - $num = gmp_div($num, 256); - } - - return pack('C*', ...array_reverse($ret)); - } - - /** - * @throws \RuntimeException - */ - private function decrypt(string $data, string $key):string{ - - if(strlen($data) !== strlen($key)){ - throw new RuntimeException('The decryption key size and data size doesn\'t match'); - } - - $data = unpack('C*', $data); - $key = unpack('C*', $key); - - foreach($data as $i => &$c){ - $c ^= $key[$i]; - } - - return pack('C*', ...$data); - } - -} diff --git a/src/Authenticators/HOTP.php b/src/Authenticators/HOTP.php index 82da335..4252932 100644 --- a/src/Authenticators/HOTP.php +++ b/src/Authenticators/HOTP.php @@ -7,10 +7,12 @@ * @copyright 2023 smiley * @license MIT */ +declare(strict_types=1); namespace chillerlan\Authenticator\Authenticators; use RuntimeException; +use function array_merge; use function hash_equals; use function hash_hmac; use function pack; @@ -25,16 +27,12 @@ */ class HOTP extends AuthenticatorAbstract{ - /** - * @inheritDoc - */ - public function getCounter(int $data = null):int{ + public const MODE = self::HOTP; + + public function getCounter(?int $data = null):int{ return ($data ?? 0); } - /** - * @inheritDoc - */ public function getHMAC(int $counter):string{ if($this->secret === null){ @@ -42,45 +40,58 @@ public function getHMAC(int $counter):string{ } // @codeCoverageIgnoreStart $data = (PHP_INT_SIZE < 8) + // 32-bit ? "\x00\x00\x00\x00".pack('N', $counter) + // 64-bit : pack('J', $counter); // @codeCoverageIgnoreEnd return hash_hmac($this->options->algorithm, $data, $this->secret, true); } - /** - * @inheritDoc - */ public function getCode(string $hmac):int{ $data = unpack('C*', $hmac); - $b = ($data[strlen($hmac)] & 0xF); - // phpcs:ignore - return (($data[$b + 1] & 0x7F) << 24) | ($data[$b + 2] << 16) | ($data[$b + 3] << 8) | $data[$b + 4]; + + if($data === false){ + throw new RuntimeException('error while unpacking HMAC'); // @codeCoverageIgnore + } + + $b = ($data[strlen($hmac)] & 0xF); + + return (($data[$b + 1] & 0x7F) << 24) | ($data[$b + 2] << 16) | ($data[$b + 3] << 8) | $data[$b + 4]; // phpcs:ignore } - /** - * @inheritDoc - */ public function getOTP(int $code):string{ $code %= (10 ** $this->options->digits); return str_pad((string)$code, $this->options->digits, '0', STR_PAD_LEFT); } - /** - * @inheritDoc - */ - public function code(int $data = null):string{ + public function code(?int $data = null):string{ $hmac = $this->getHMAC($this->getCounter($data)); return $this->getOTP($this->getCode($hmac)); } - /** - * @inheritDoc - */ - public function verify(string $otp, int $data = null):bool{ + public function verify(string $otp, ?int $data = null):bool{ return hash_equals($this->code($data), $otp); } + protected function getUriParams(string $issuer, ?int $counter = null):array{ + + $params = [ + 'secret' => $this->getSecret(), + 'issuer' => $issuer, + 'counter' => $this->getCounter($counter), + ]; + + if(!$this->options->omitUriSettings){ + $params = array_merge($params, [ + 'digits' => $this->options->digits, + 'algorithm' => $this->options->algorithm, + ]); + } + + return $params; + } + } diff --git a/src/Authenticators/SteamGuard.php b/src/Authenticators/SteamGuard.php index f041634..79bf2eb 100644 --- a/src/Authenticators/SteamGuard.php +++ b/src/Authenticators/SteamGuard.php @@ -9,18 +9,19 @@ * * @noinspection PhpComposerExtensionStubsInspection */ +declare(strict_types=1); namespace chillerlan\Authenticator\Authenticators; use chillerlan\Authenticator\Common\Base64; use RuntimeException; -use function curl_close; use function curl_exec; use function curl_getinfo; use function curl_init; use function curl_setopt_array; use function floor; use function intdiv; +use function is_array; use function json_decode; use function sprintf; use function time; @@ -37,21 +38,17 @@ */ final class SteamGuard extends TOTP{ + public const MODE = self::STEAM_GUARD; + private const steamCodeChars = '23456789BCDFGHJKMNPQRTVWXY'; private const steamTimeURL = 'https://api.steampowered.com/ITwoFactorService/QueryTime/v0001'; - /** - * @inheritDoc - */ public function setSecret(string $encodedSecret):AuthenticatorInterface{ $this->secret = Base64::decode($this->checkEncodedSecret($encodedSecret)); return $this; } - /** - * @inheritDoc - */ public function getSecret():string{ if($this->secret === null){ @@ -61,27 +58,13 @@ public function getSecret():string{ return Base64::encode($this->secret); } - /** - * @inheritDoc - * @codeCoverageIgnore - */ - public function createSecret(int $length = null):string{ - throw new RuntimeException('Not implemented'); - } - - /** - * @inheritDoc - */ - public function getCounter(int $data = null):int{ + public function getCounter(?int $data = null):int{ // the period is fixed to 30 seconds for Steam Guard $this->options->period = 30; return parent::getCounter($data); } - /** - * @inheritDoc - */ public function getHMAC(int $counter):string{ // algorithm is fixed to sha1 for Steam Guard $this->options->algorithm = self::ALGO_SHA1; @@ -89,9 +72,6 @@ public function getHMAC(int $counter):string{ return parent::getHMAC($counter); } - /** - * @inheritDoc - */ public function getOTP(int $code):string{ $str = ''; $len = 26; // strlen($this::steamCodeChars) @@ -105,8 +85,11 @@ public function getOTP(int $code):string{ return $str; } + public function getUri(string $label, string $issuer, ?int $counter = null):string{ + throw new RuntimeException('Not supported'); + } + /** - * @inheritDoc * @throws \RuntimeException */ public function getServerTime():int{ @@ -127,21 +110,24 @@ public function getServerTime():int{ $ch = curl_init($this::steamTimeURL); + // it's almost impossible to run into this, but hey, phpstan happy + if($ch === false){ + throw new RuntimeException('curl_init error'); // @codeCoverageIgnore + } + curl_setopt_array($ch, $options); $response = curl_exec($ch); $info = curl_getinfo($ch); - curl_close($ch); - - if($info['http_code'] !== 200){ + if($info['http_code'] !== 200 || $response === false){ // I'm not going to investigate the error further as this shouldn't happen usually throw new RuntimeException(sprintf('Steam API request error: HTTP/%s', $info['http_code'])); // @codeCoverageIgnore } $json = json_decode($response, true); - if(empty($json) || !isset($json['response']['server_time'])){ + if(!is_array($json) || !isset($json['response']['server_time'])){ throw new RuntimeException('Unable to decode Steam API response'); // @codeCoverageIgnore } diff --git a/src/Authenticators/TOTP.php b/src/Authenticators/TOTP.php index 63cf322..bc2ef97 100644 --- a/src/Authenticators/TOTP.php +++ b/src/Authenticators/TOTP.php @@ -7,9 +7,11 @@ * @copyright 2023 smiley * @license MIT */ +declare(strict_types=1); namespace chillerlan\Authenticator\Authenticators; +use function array_merge; use function floor; use function hash_equals; use function time; @@ -19,10 +21,9 @@ */ class TOTP extends HOTP{ - /** - * @inheritDoc - */ - public function getCounter(int $data = null):int{ + public const MODE = self::TOTP; + + public function getCounter(?int $data = null):int{ $data ??= time(); if($this->options->useLocalTime === false){ @@ -32,10 +33,7 @@ public function getCounter(int $data = null):int{ return (int)floor(($data + $this->options->time_offset) / $this->options->period); } - /** - * @inheritDoc - */ - public function verify(string $otp, int $data = null):bool{ + public function verify(string $otp, ?int $data = null):bool{ $limit = $this->options->adjacent; if($limit === 0){ @@ -56,4 +54,22 @@ public function verify(string $otp, int $data = null):bool{ return false; } + protected function getUriParams(string $issuer, ?int $counter = null):array{ + + $params = [ + 'secret' => $this->getSecret(), + 'issuer' => $issuer, + ]; + + if(!$this->options->omitUriSettings){ + $params = array_merge($params, [ + 'digits' => $this->options->digits, + 'algorithm' => $this->options->algorithm, + 'period' => $this->options->period, + ]); + } + + return $params; + } + } diff --git a/src/Common/Base32.php b/src/Common/Base32.php index 2fb6a9b..2b63799 100644 --- a/src/Common/Base32.php +++ b/src/Common/Base32.php @@ -7,6 +7,7 @@ * @copyright 2023 smiley * @license MIT */ +declare(strict_types=1); namespace chillerlan\Authenticator\Common; diff --git a/src/Common/Base64.php b/src/Common/Base64.php index f199a79..cfa3328 100644 --- a/src/Common/Base64.php +++ b/src/Common/Base64.php @@ -7,6 +7,7 @@ * @copyright 2023 smiley * @license MIT */ +declare(strict_types=1); namespace chillerlan\Authenticator\Common; @@ -36,7 +37,7 @@ class Base64{ public static function encode(string $str):string{ if(function_exists('sodium_bin2base64')){ - return sodium_bin2base64($str, \SODIUM_BASE64_VARIANT_ORIGINAL); + return \sodium_bin2base64($str, \SODIUM_BASE64_VARIANT_ORIGINAL); } return ConstantTimeBase64::encode($str); @@ -49,7 +50,7 @@ public static function decode(string $base64):string{ self::checkCharacterSet($base64); if(function_exists('sodium_base642bin')){ - return sodium_base642bin($base64, \SODIUM_BASE64_VARIANT_ORIGINAL); + return \sodium_base642bin($base64, \SODIUM_BASE64_VARIANT_ORIGINAL); } return ConstantTimeBase64::decode($base64); diff --git a/src/Common/Hex.php b/src/Common/Hex.php index 8a1db69..a27ea05 100644 --- a/src/Common/Hex.php +++ b/src/Common/Hex.php @@ -7,6 +7,7 @@ * @copyright 2023 smiley * @license MIT */ +declare(strict_types=1); namespace chillerlan\Authenticator\Common; @@ -15,7 +16,7 @@ use function preg_match; /** - * + * Class to provide hexadecimal encoding/decoding of strings using constant time functions */ class Hex{ @@ -27,21 +28,21 @@ class Hex{ public const CHARSET = '1234567890ABCDEFabcdef'; /** - * Encode a raw-binary to hexadecimal + * Encode a string to hexadecimal * * @codeCoverageIgnore */ public static function encode(string $str):string{ if(function_exists('sodium_bin2hex')){ - return sodium_bin2hex($str); + return \sodium_bin2hex($str); } return ConstantTimeHex::encode($str); } /** - * Decode a raw-binary string from hexadecimal + * Decode a string from hexadecimal * * @codeCoverageIgnore */ @@ -49,7 +50,7 @@ public static function decode(string $hex):string{ self::checkCharacterSet($hex); if(function_exists('sodium_hex2bin')){ - return sodium_hex2bin($hex); + return \sodium_hex2bin($hex); } return ConstantTimeHex::decode($hex); diff --git a/tests/AuthenticatorOptionsTest.php b/tests/AuthenticatorOptionsTest.php index 782d8ac..b4088de 100644 --- a/tests/AuthenticatorOptionsTest.php +++ b/tests/AuthenticatorOptionsTest.php @@ -7,6 +7,7 @@ * @copyright 2019 smiley * @license MIT */ +declare(strict_types=1); namespace chillerlan\AuthenticatorTest; @@ -88,7 +89,7 @@ public function testSetAdjacent():void{ public function testSetAdjacentException():void{ $this->expectException(InvalidArgumentException::class); - $this->expectExceptionMessage('Invalid adjacent'); + $this->expectExceptionMessage('Invalid number of adjacent codes'); $this->options->adjacent = -1; } diff --git a/tests/AuthenticatorTest.php b/tests/AuthenticatorTest.php index a3d88f7..6206cd9 100644 --- a/tests/AuthenticatorTest.php +++ b/tests/AuthenticatorTest.php @@ -7,6 +7,7 @@ * @copyright 2015 Smiley * @license MIT */ +declare(strict_types=1); namespace chillerlan\AuthenticatorTest; @@ -31,7 +32,7 @@ protected function setUp():void{ } public function testSetSecretViaConstruct():void{ - $this->authenticator = new Authenticator(null, self::secret); + $this->authenticator = new Authenticator($this->options, self::secret); $this::assertSame(self::secret, $this->authenticator->getSecret()); } @@ -44,19 +45,19 @@ public function testGetUri():void{ $this::assertSame( sprintf('otpauth://totp/%s?secret=%s&issuer=%s&digits=6&algorithm=SHA1&period=30', $label, self::secret, $issuer), - $this->authenticator->getUri(self::label, self::issuer) + $this->authenticator->getUri(self::label, self::issuer), ); $this->options->digits = 8; $this::assertSame( sprintf('otpauth://totp/%s?secret=%s&issuer=%s&digits=8&algorithm=SHA1&period=30', $label, self::secret, $issuer), - $this->authenticator->getUri(self::label, self::issuer) + $this->authenticator->getUri(self::label, self::issuer), ); $this->options->period = 45; $this::assertSame( sprintf('otpauth://totp/%s?secret=%s&issuer=%s&digits=8&algorithm=SHA1&period=45', $label, self::secret, $issuer), - $this->authenticator->getUri(self::label, self::issuer) + $this->authenticator->getUri(self::label, self::issuer), ); $this->options->mode = AuthenticatorInterface::HOTP; @@ -66,24 +67,24 @@ public function testGetUri():void{ ->setSecret(self::secret); $this::assertSame( - sprintf('otpauth://hotp/%s?secret=%s&issuer=%s&digits=8&algorithm=SHA1&counter=42', $label, self::secret, $issuer), - $this->authenticator->getUri(self::label, self::issuer, 42) + sprintf('otpauth://hotp/%s?secret=%s&issuer=%s&counter=42&digits=8&algorithm=SHA1', $label, self::secret, $issuer), + $this->authenticator->getUri(self::label, self::issuer, 42), ); $this->options->algorithm = AuthenticatorInterface::ALGO_SHA512; $this::assertSame( - sprintf('otpauth://hotp/%s?secret=%s&issuer=%s&digits=8&algorithm=SHA512', $label, self::secret, $issuer), - $this->authenticator->getUri(self::label, self::issuer) + sprintf('otpauth://hotp/%s?secret=%s&issuer=%s&counter=0&digits=8&algorithm=SHA512', $label, self::secret, $issuer), + $this->authenticator->getUri(self::label, self::issuer, 0), ); // test omit settings $this::assertSame( - sprintf('otpauth://%s/%s?secret=%s&issuer=%s', 'hotp', $label, self::secret, $issuer), - $this->authenticator->getUri(self::label, self::issuer, 42, true) + sprintf('otpauth://%s/%s?secret=%s&issuer=%s&counter=42', 'hotp', $label, self::secret, $issuer), + $this->authenticator->getUri(self::label, self::issuer, 42, true), ); } - public function testGetUriEmptyLabelException(){ + public function testGetUriEmptyLabelException():void{ $this->expectException(InvalidArgumentException::class); $this->expectExceptionMessage('$label and $issuer cannot be empty'); diff --git a/tests/Authenticators/AuthenticatorInterfaceTestAbstract.php b/tests/Authenticators/AuthenticatorInterfaceTestAbstract.php index e588e90..0896524 100644 --- a/tests/Authenticators/AuthenticatorInterfaceTestAbstract.php +++ b/tests/Authenticators/AuthenticatorInterfaceTestAbstract.php @@ -7,6 +7,7 @@ * @copyright 2023 smiley * @license MIT */ +declare(strict_types=1); namespace chillerlan\AuthenticatorTest\Authenticators; @@ -18,9 +19,6 @@ use RuntimeException; use function strlen; -/** - * - */ abstract class AuthenticatorInterfaceTestAbstract extends TestCase{ protected AuthenticatorOptions $options; @@ -68,7 +66,7 @@ public function testGetSecretException():void{ public function testCreateSecretDefaultLength():void{ $this::assertSame( $this->options->secret_length, - strlen(Base32::decode($this->authenticatorInterface->createSecret())) + strlen(Base32::decode($this->authenticatorInterface->createSecret())), ); } diff --git a/tests/Authenticators/BattleNetTest.php b/tests/Authenticators/BattleNetTest.php deleted file mode 100644 index ec0da89..0000000 --- a/tests/Authenticators/BattleNetTest.php +++ /dev/null @@ -1,145 +0,0 @@ - - * @copyright 2023 smiley - * @license MIT - */ - -namespace chillerlan\AuthenticatorTest\Authenticators; - -use chillerlan\Authenticator\AuthenticatorOptions; -use chillerlan\Authenticator\Authenticators\{AuthenticatorInterface, BattleNet}; -use chillerlan\Authenticator\Common\Hex; -use Generator; -use function date; -use function dechex; -use function is_int; -use const PHP_INT_SIZE; - -/** - * @property \chillerlan\Authenticator\Authenticators\BattleNet $authenticatorInterface - */ -class BattleNetTest extends AuthenticatorInterfaceTestAbstract{ - - protected const secret = '3132333435363738393031323334353637383930'; - - protected const BattleNetVectors = [ - // timestamps and time slices from RFC 6238, see https://tools.ietf.org/html/rfc6238#page-14 - [ 59, '1', '94287082'], - [ 1111111109, '23523ec', '07081804'], - [ 1111111111, '23523ed', '14050471'], - [ 1234567890, '273ef07', '89005924'], - [ 2000000000, '3f940aa', '69279037'], - // 64bit only - [20000000000, '27bc86aa', '65353130'], - ]; - - protected function getInstance(AuthenticatorOptions $options):AuthenticatorInterface{ - return new BattleNet($options); - } - - public function testSetGetSecret():void{ - $this->authenticatorInterface->setSecret($this::secret); - - $secret = $this->authenticatorInterface->getSecret(); - - $this::assertSame($this::secret, $secret); - $this::assertSame($this::rawsecret, Hex::decode($secret)); - } - - public function testCreateSecretDefaultLength():void{ - /** @noinspection PhpUnitTestFailedLineInspection */ - $this::markTestSkipped('N/A'); - } - - public function testCreateSecretWithLength():void{ - /** @noinspection PhpUnitTestFailedLineInspection */ - $this::markTestSkipped('N/A'); - } - - public function testCreateSecretCheckCharacterSet():void{ - /** @noinspection PhpUnitTestFailedLineInspection */ - $this::markTestSkipped('N/A'); - } - - public function testCreateSecretException():void{ - /** @noinspection PhpUnitTestFailedLineInspection */ - $this::markTestSkipped('N/A'); - } - - /** - * @see https://github.com/winauth/winauth/blob/c57132f57b8a90e5219c628deb591f4603f27cb0/Authenticator/BattleNetAuthenticator.cs#L450-L451 - */ - public function testRestoreSecret():void{ - $serial = 'US-1306-2525-4376'; - $restore_code = 'CR24KPKF51'; - - $data = $this->authenticatorInterface->restoreSecret($serial, $restore_code); - - $this::assertSame($serial, $data['serial']); - $this::assertSame($restore_code, $data['restore_code']); - $this::assertSame('US', $data['region']); - $this::assertSame('0402761151586faf1cf74efbf3dcb831063909a127220de374d5b4876f12bfcd', $data['public_key']); - $this::assertSame('7b0bfa8230e54424ab51777dadbfd5374143e3b0', $data['secret']); - } - - /** - * Timestamps and -slices from the RFC6238 page, codes from a verified implementation - * - * @see https://tools.ietf.org/html/rfc6238#page-14 - */ - public static function battleNetVectors():Generator{ - foreach(self::BattleNetVectors as [$timestamp, $timeslice, $totp]){ - // skip 64bit numbers on 32bit PHP - if(PHP_INT_SIZE < 8 && !is_int($timestamp)){ - continue; - } - - yield date('Y-m-d H:i:s', $timestamp) => [$timestamp, $timeslice, $totp]; - } - } - - /** - * @dataProvider battleNetVectors - */ - public function testIntermediateValues(int $timestamp, string $timeslice, string $totp):void{ - $this->authenticatorInterface->setSecret($this::secret); - - $timeslice_intermediate = $this->authenticatorInterface->getCounter($timestamp); - - $this::assertSame($timeslice, dechex($timeslice_intermediate)); - - $hmac_intermediate = $this->authenticatorInterface->getHMAC($timeslice_intermediate); - $code_intermediate = $this->authenticatorInterface->getCode($hmac_intermediate); - $code_formatted = $this->authenticatorInterface->getOTP($code_intermediate); - - $this::assertSame($totp, $code_formatted); - } - - /** - * @dataProvider battleNetVectors - */ - public function testAdjacent(int $timestamp, string $timeslice, string $totp):void{ - $adjacent = 20; - $limit = (2 * $adjacent); - - $this->authenticatorInterface->setSecret($this::secret); - - $this->options->adjacent = $adjacent; - // phpcs:ignore - for($i = -$limit; $i <= $limit; $i++){ - $this->options->time_offset = ($i * $this->options->period); - - $verify = $this->authenticatorInterface->verify($totp, $timestamp); - - ($i < -$adjacent || $i > $adjacent) - ? $this::assertFalse($verify) - : $this::assertTrue($verify); - } - - } - -} diff --git a/tests/Authenticators/HOTPTest.php b/tests/Authenticators/HOTPTest.php index c2ac298..90549e9 100644 --- a/tests/Authenticators/HOTPTest.php +++ b/tests/Authenticators/HOTPTest.php @@ -7,6 +7,7 @@ * @copyright 2023 smiley * @license MIT */ +declare(strict_types=1); namespace chillerlan\AuthenticatorTest\Authenticators; @@ -16,9 +17,6 @@ use function bin2hex; use function sprintf; -/** - * - */ class HOTPTest extends AuthenticatorInterfaceTestAbstract{ /** diff --git a/tests/Authenticators/SteamGuardTest.php b/tests/Authenticators/SteamGuardTest.php index a44c2e7..cbc13ec 100644 --- a/tests/Authenticators/SteamGuardTest.php +++ b/tests/Authenticators/SteamGuardTest.php @@ -7,6 +7,7 @@ * @copyright 2023 smiley * @license MIT */ +declare(strict_types=1); namespace chillerlan\AuthenticatorTest\Authenticators; @@ -17,6 +18,7 @@ use function date; use function dechex; use function is_int; +use function strlen; use const PHP_INT_SIZE; /** @@ -51,23 +53,26 @@ public function testSetGetSecret():void{ } public function testCreateSecretDefaultLength():void{ - /** @noinspection PhpUnitTestFailedLineInspection */ - $this::markTestSkipped('N/A'); + $this::assertSame( + $this->options->secret_length, + strlen(Base64::decode($this->authenticatorInterface->createSecret())), + ); } public function testCreateSecretWithLength():void{ - /** @noinspection PhpUnitTestFailedLineInspection */ - $this::markTestSkipped('N/A'); + + for($secretLength = 16; $secretLength <= 512; $secretLength += 8){ + $secret = Base64::decode($this->authenticatorInterface->createSecret($secretLength)); + + $this::assertSame($secretLength, strlen($secret)); + } + } public function testCreateSecretCheckCharacterSet():void{ - /** @noinspection PhpUnitTestFailedLineInspection */ - $this::markTestSkipped('N/A'); - } + $secret = $this->authenticatorInterface->createSecret(32); - public function testCreateSecretException():void{ - /** @noinspection PhpUnitTestFailedLineInspection */ - $this::markTestSkipped('N/A'); + $this::assertMatchesRegularExpression('#^['.Base64::CHARSET.']+$#', $secret); } /** @@ -107,7 +112,7 @@ public function testIntermediateValues(int $timestamp, string $timeslice, string * @dataProvider steamGuardVectors */ public function testAdjacent(int $timestamp, string $timeslice, string $totp):void{ - $adjacent = 20; + $adjacent = 10; $limit = (2 * $adjacent); $this->authenticatorInterface->setSecret($this::secret); diff --git a/tests/Authenticators/TOTPTest.php b/tests/Authenticators/TOTPTest.php index 7e08ea2..dad4374 100644 --- a/tests/Authenticators/TOTPTest.php +++ b/tests/Authenticators/TOTPTest.php @@ -7,6 +7,7 @@ * @copyright 2023 smiley * @license MIT */ +declare(strict_types=1); namespace chillerlan\AuthenticatorTest\Authenticators; @@ -19,9 +20,6 @@ use function sprintf; use const PHP_INT_SIZE; -/** - * - */ class TOTPTest extends AuthenticatorInterfaceTestAbstract{ /** @@ -102,7 +100,7 @@ public function testIntermediateValues(string $algorithm, int $timestamp, string * @dataProvider totpVectors */ public function testAdjacent(string $algorithm, int $timestamp, string $timeslice, int $code, string $totp):void{ - $adjacent = 20; + $adjacent = 10; $limit = (2 * $adjacent); $this->authenticatorInterface->setSecret(self::secrets[$algorithm]); diff --git a/tests/Common/Base32Test.php b/tests/Common/Base32Test.php index c03a907..4ad8f3a 100644 --- a/tests/Common/Base32Test.php +++ b/tests/Common/Base32Test.php @@ -7,6 +7,7 @@ * @copyright 2016 Smiley * @license MIT */ +declare(strict_types=1); namespace chillerlan\AuthenticatorTest\Common; @@ -16,6 +17,9 @@ class Base32Test extends TestCase{ + /** + * @phpstan-return array> + */ public static function base32DataProvider():array{ return [ ['a' , 'ME' ], diff --git a/tests/Common/Base64Test.php b/tests/Common/Base64Test.php index 7c8d0fa..9fdbcb1 100644 --- a/tests/Common/Base64Test.php +++ b/tests/Common/Base64Test.php @@ -7,6 +7,7 @@ * @copyright 2023 smiley * @license MIT */ +declare(strict_types=1); namespace chillerlan\AuthenticatorTest\Common; @@ -16,11 +17,11 @@ use function base64_decode; use function base64_encode; -/** - * - */ class Base64Test extends TestCase{ + /** + * @phpstan-return array> + */ public static function base64DataProvider():array{ return [ ['a' , 'YQ==' ], @@ -52,7 +53,7 @@ public function testDecode(string $str, string $base64):void{ $this::assertSame($str, $decoded); // test against native PHP - $this::assertSame(base64_decode($base64), $decoded); + $this::assertSame(base64_decode($base64, true), $decoded); } /** diff --git a/tests/Common/HexTest.php b/tests/Common/HexTest.php index 298ee39..4156cf1 100644 --- a/tests/Common/HexTest.php +++ b/tests/Common/HexTest.php @@ -7,6 +7,7 @@ * @copyright 2023 smiley * @license MIT */ +declare(strict_types=1); namespace chillerlan\AuthenticatorTest\Common; @@ -16,11 +17,11 @@ use function bin2hex; use function hex2bin; -/** - * - */ class HexTest extends TestCase{ + /** + * @phpstan-return array> + */ public static function hexDataProvider():array{ return [ ['a' , '61' ],