diff --git a/.arcconfig b/.arcconfig deleted file mode 100644 index ba871f4..0000000 --- a/.arcconfig +++ /dev/null @@ -1,6 +0,0 @@ -{ - "unit.engine": "Firehed\\Arctools\\Unit\\PHPUnitTestEngine", - "load": [ - "vendor/firehed/arctools/src" - ] -} diff --git a/.gitattributes b/.gitattributes new file mode 100644 index 0000000..3a1617e --- /dev/null +++ b/.gitattributes @@ -0,0 +1,6 @@ +/tests export-ignore +/.editorconfig export-ignore +/.gitattributes export-ignore +/.gitignore export-ignore + +*.php diff=php diff --git a/.github/dependabot.yml b/.github/dependabot.yml new file mode 100644 index 0000000..a839255 --- /dev/null +++ b/.github/dependabot.yml @@ -0,0 +1,11 @@ +# To get started with Dependabot version updates, you'll need to specify which +# package ecosystems to update and where the package manifests are located. +# Please see the documentation for all configuration options: +# https://help.github.com/github/administering-a-repository/configuration-options-for-dependency-updates + +version: 2 +updates: + - package-ecosystem: "composer" + directory: "/" + schedule: + interval: "daily" diff --git a/.github/workflows/lint.yml b/.github/workflows/lint.yml new file mode 100644 index 0000000..ce4db10 --- /dev/null +++ b/.github/workflows/lint.yml @@ -0,0 +1,41 @@ +name: Lint + +on: + push: + branches: + - master + pull_request: + # Run on all PRs + +env: + CI: "true" + +jobs: + phpcs: + runs-on: ubuntu-latest + steps: + - name: Check out code + uses: actions/checkout@v2 + + - name: Setup PHP + uses: shivammathur/setup-php@v2 + + - name: Cache Composer packages + id: composer-cache + uses: actions/cache@v2 + with: + path: vendor + key: ${{ runner.os }}-php-${{ hashFiles('**/composer.json') }} + restore-keys: | + ${{ runner.os }}-php- + + - name: Install dependencies + run: composer update + --no-ansi + --no-interaction + --no-progress + --no-suggest + --prefer-dist + + - name: PHPCS + run: composer phpcs diff --git a/.github/workflows/static-analysis.yml b/.github/workflows/static-analysis.yml new file mode 100644 index 0000000..d1fd5c3 --- /dev/null +++ b/.github/workflows/static-analysis.yml @@ -0,0 +1,41 @@ +name: Static analysis + +on: + push: + branches: + - master + pull_request: + # Run on all PRs + +env: + CI: "true" + +jobs: + phpstan: + runs-on: ubuntu-latest + steps: + - name: Check out code + uses: actions/checkout@v2 + + - name: Setup PHP + uses: shivammathur/setup-php@v2 + + - name: Cache Composer packages + id: composer-cache + uses: actions/cache@v2 + with: + path: vendor + key: ${{ runner.os }}-php-${{ hashFiles('**/composer.json') }} + restore-keys: | + ${{ runner.os }}-php- + + - name: Install dependencies + run: composer update + --no-ansi + --no-interaction + --no-progress + --no-suggest + --prefer-dist + + - name: PHPStan + run: composer phpstan diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml new file mode 100644 index 0000000..1ca349a --- /dev/null +++ b/.github/workflows/test.yml @@ -0,0 +1,80 @@ +name: Test + +on: + push: + branches: + - master + pull_request: + # Run on all PRs + +env: + CI: "true" + +jobs: + phpunit: + runs-on: ubuntu-latest + strategy: + fail-fast: false + matrix: + dependencies: + - 'high' + - 'low' + php: + - '7.2' + - '7.3' + - '7.4' + - '8.0' + - '8.1' + exclude: + - php: '8.1' + dependencies: 'low' + + steps: + - name: Check out code + uses: actions/checkout@v2 + + - name: Setup PHP + uses: shivammathur/setup-php@v2 + with: + coverage: pcov + ini-values: zend.assertions=1, assert.exception=1, error_reporting=-1 + php-version: ${{ matrix.php }} + + - name: Cache Composer packages + id: composer-cache + uses: actions/cache@v2 + with: + path: vendor + key: ${{ runner.os }}-php-${{ matrix.dependencies }}-${{ matrix.php }}-${{ hashFiles('**/composer.json') }} + restore-keys: | + ${{ runner.os }}-php-${{ matrix.dependencies }}-${{ matrix.php }}-${{ hashFiles('**/composer.json') }} + ${{ runner.os }}-php-${{ matrix.dependencies }}-${{ matrix.php }}- + ${{ runner.os }}-php-${{ matrix.dependencies }}- + ${{ runner.os }}-php- + + - name: Install highest dependencies + if: ${{ matrix.dependencies == 'high' }} + run: composer update + --no-ansi + --no-interaction + --no-progress + --no-suggest + --prefer-dist + + - name: Install lowest dependencies + if: ${{ matrix.dependencies == 'low' }} + run: composer update + --no-ansi + --no-interaction + --no-progress + --no-suggest + --prefer-dist + --prefer-lowest + + - name: PHPUnit + run: vendor/bin/phpunit + --coverage-clover coverage.xml + + - name: Submit code coverage + if: ${{ always() }} + uses: codecov/codecov-action@v2 diff --git a/.gitignore b/.gitignore index 38d8e6d..010fb9a 100644 --- a/.gitignore +++ b/.gitignore @@ -1,3 +1,6 @@ /examples/users/* /examples/vendor/ /vendor/ +# Suggested for libraries +composer.lock +.phpunit.result.cache diff --git a/.travis.yml b/.travis.yml deleted file mode 100644 index 9bb78c4..0000000 --- a/.travis.yml +++ /dev/null @@ -1,9 +0,0 @@ -language: php -php: - - '7.0' - -# From PHPUnit's config -install: - - travis_retry composer install --no-interaction --prefer-source - -script: php -d mbstring.func_overload=7 vendor/bin/phpunit --coverage-text --whitelist src/ tests/ diff --git a/README.md b/README.md index 0d30fcc..781206d 100644 --- a/README.md +++ b/README.md @@ -2,6 +2,11 @@ A PHP implementation of the FIDO U2F authentication standard +[![Lint](https://github.com/Firehed/u2f-php/actions/workflows/lint.yml/badge.svg)](https://github.com/Firehed/u2f-php/actions/workflows/lint.yml) +[![Static analysis](https://github.com/Firehed/u2f-php/actions/workflows/static-analysis.yml/badge.svg)](https://github.com/Firehed/u2f-php/actions/workflows/static-analysis.yml) +[![Test](https://github.com/Firehed/u2f-php/actions/workflows/test.yml/badge.svg)](https://github.com/Firehed/u2f-php/actions/workflows/test.yml) +[![codecov](https://codecov.io/gh/Firehed/u2f-php/branch/master/graph/badge.svg?token=8VxRoJxmNL)](https://codecov.io/gh/Firehed/u2f-php) + ## Introduction U2F, or Universal Second Factor, is a new authentication protocol designed "to augment the security of their existing password infrastructure by adding a strong second factor to user login"[1](https://fidoalliance.org/specs/fido-u2f-v1.0-nfc-bt-amendment-20150514/fido-u2f-overview.html#background). It allows websites to replace the need for a companion app (such as Google Authenticator) with a single hardware token that will work across any website supporting the U2F protocol. @@ -20,6 +25,13 @@ Additional resources: * [FIDO U2F Overview](https://fidoalliance.org/specs/fido-u2f-v1.0-nfc-bt-amendment-20150514/fido-u2f-overview.html) * [FIDO U2F Javascript API](https://fidoalliance.org/specs/fido-u2f-v1.0-nfc-bt-amendment-20150514/fido-u2f-javascript-api.html) +## Installation + +`composer require firehed/u2f` + +Note: you **must not** be using the deprecated `mbstring.func_overload` functionality, which can completely break working on binary data. +The library will immediately throw an exception if you have it enabled. + ## Usage Usage will be described in three parts: setup, registration, and authentication. @@ -149,9 +161,7 @@ See its README for more information. ## Tests -If you are using Arcanist, `arc unit` workflows will work as expected. - -Otherwise, all tests are in the `tests/` directory and can be run with `vendor/bin/phpunit tests/`. +All tests are in the `tests/` directory and can be run with `vendor/bin/phpunit`. ## License diff --git a/composer.json b/composer.json index 7214806..4c0da2f 100644 --- a/composer.json +++ b/composer.json @@ -13,11 +13,13 @@ ], "homepage": "https://github.com/Firehed/u2f-php", "require": { - "php": ">=7.0" + "php": ">=7.2" }, "require-dev": { - "firehed/arctools": "^1", - "phpunit/phpunit": "^5.2" + "phpstan/phpstan": "^0.12", + "phpunit/phpunit": "^8.5", + "squizlabs/php_codesniffer": "^3.2", + "phpstan/phpstan-phpunit": "^0.12" }, "autoload": { "psr-4": { @@ -37,5 +39,17 @@ "name": "Eric Stern", "email": "eric@ericstern.com" } - ] + ], + "scripts": { + "test": [ + "@phpunit", + "@phpstan", + "@phpcs" + ], + "coverage": "phpunit --coverage-html build; open build/index.html", + "autofix": "phpcbf src lib tests db", + "phpunit": "phpunit", + "phpstan": "phpstan analyse --no-progress", + "phpcs": "phpcs" + } } diff --git a/composer.lock b/composer.lock deleted file mode 100644 index a821be3..0000000 --- a/composer.lock +++ /dev/null @@ -1,1150 +0,0 @@ -{ - "_readme": [ - "This file locks the dependencies of your project to a known state", - "Read more about it at https://getcomposer.org/doc/01-basic-usage.md#composer-lock-the-lock-file", - "This file is @generated automatically" - ], - "hash": "619e41093d2fdb704a016f75703d1297", - "content-hash": "6206adc173e7b48e1071edb7819d70a8", - "packages": [], - "packages-dev": [ - { - "name": "doctrine/instantiator", - "version": "1.0.5", - "source": { - "type": "git", - "url": "https://github.com/doctrine/instantiator.git", - "reference": "8e884e78f9f0eb1329e445619e04456e64d8051d" - }, - "dist": { - "type": "zip", - "url": "https://api.github.com/repos/doctrine/instantiator/zipball/8e884e78f9f0eb1329e445619e04456e64d8051d", - "reference": "8e884e78f9f0eb1329e445619e04456e64d8051d", - "shasum": "" - }, - "require": { - "php": ">=5.3,<8.0-DEV" - }, - "require-dev": { - "athletic/athletic": "~0.1.8", - "ext-pdo": "*", - "ext-phar": "*", - "phpunit/phpunit": "~4.0", - "squizlabs/php_codesniffer": "~2.0" - }, - "type": "library", - "extra": { - "branch-alias": { - "dev-master": "1.0.x-dev" - } - }, - "autoload": { - "psr-4": { - "Doctrine\\Instantiator\\": "src/Doctrine/Instantiator/" - } - }, - "notification-url": "https://packagist.org/downloads/", - "license": [ - "MIT" - ], - "authors": [ - { - "name": "Marco Pivetta", - "email": "ocramius@gmail.com", - "homepage": "http://ocramius.github.com/" - } - ], - "description": "A small, lightweight utility to instantiate objects in PHP without invoking their constructors", - "homepage": "https://github.com/doctrine/instantiator", - "keywords": [ - "constructor", - "instantiate" - ], - "time": "2015-06-14 21:17:01" - }, - { - "name": "firehed/arctools", - "version": "1.1.0", - "source": { - "type": "git", - "url": "https://github.com/Firehed/arctools.git", - "reference": "033cc82e777dd10a5416f544b37afb84877332ca" - }, - "dist": { - "type": "zip", - "url": "https://api.github.com/repos/Firehed/arctools/zipball/033cc82e777dd10a5416f544b37afb84877332ca", - "reference": "033cc82e777dd10a5416f544b37afb84877332ca", - "shasum": "" - }, - "require-dev": { - "phpunit/phpunit": "~5.0" - }, - "type": "library", - "autoload": { - "psr-4": { - "Firehed\\Arctools\\": "src/" - } - }, - "notification-url": "https://packagist.org/downloads/", - "license": [ - "MIT" - ], - "authors": [ - { - "name": "Eric Stern", - "email": "eric@ericstern.com" - } - ], - "description": "Easily integrate with Arcanist and libphutil", - "time": "2015-10-16 22:30:55" - }, - { - "name": "myclabs/deep-copy", - "version": "1.5.0", - "source": { - "type": "git", - "url": "https://github.com/myclabs/DeepCopy.git", - "reference": "e3abefcd7f106677fd352cd7c187d6c969aa9ddc" - }, - "dist": { - "type": "zip", - "url": "https://api.github.com/repos/myclabs/DeepCopy/zipball/e3abefcd7f106677fd352cd7c187d6c969aa9ddc", - "reference": "e3abefcd7f106677fd352cd7c187d6c969aa9ddc", - "shasum": "" - }, - "require": { - "php": ">=5.4.0" - }, - "require-dev": { - "doctrine/collections": "1.*", - "phpunit/phpunit": "~4.1" - }, - "type": "library", - "autoload": { - "psr-4": { - "DeepCopy\\": "src/DeepCopy/" - } - }, - "notification-url": "https://packagist.org/downloads/", - "license": [ - "MIT" - ], - "description": "Create deep copies (clones) of your objects", - "homepage": "https://github.com/myclabs/DeepCopy", - "keywords": [ - "clone", - "copy", - "duplicate", - "object", - "object graph" - ], - "time": "2015-11-07 22:20:37" - }, - { - "name": "phpdocumentor/reflection-docblock", - "version": "2.0.4", - "source": { - "type": "git", - "url": "https://github.com/phpDocumentor/ReflectionDocBlock.git", - "reference": "d68dbdc53dc358a816f00b300704702b2eaff7b8" - }, - "dist": { - "type": "zip", - "url": "https://api.github.com/repos/phpDocumentor/ReflectionDocBlock/zipball/d68dbdc53dc358a816f00b300704702b2eaff7b8", - "reference": "d68dbdc53dc358a816f00b300704702b2eaff7b8", - "shasum": "" - }, - "require": { - "php": ">=5.3.3" - }, - "require-dev": { - "phpunit/phpunit": "~4.0" - }, - "suggest": { - "dflydev/markdown": "~1.0", - "erusev/parsedown": "~1.0" - }, - "type": "library", - "extra": { - "branch-alias": { - "dev-master": "2.0.x-dev" - } - }, - "autoload": { - "psr-0": { - "phpDocumentor": [ - "src/" - ] - } - }, - "notification-url": "https://packagist.org/downloads/", - "license": [ - "MIT" - ], - "authors": [ - { - "name": "Mike van Riel", - "email": "mike.vanriel@naenius.com" - } - ], - "time": "2015-02-03 12:10:50" - }, - { - "name": "phpspec/prophecy", - "version": "v1.6.0", - "source": { - "type": "git", - "url": "https://github.com/phpspec/prophecy.git", - "reference": "3c91bdf81797d725b14cb62906f9a4ce44235972" - }, - "dist": { - "type": "zip", - "url": "https://api.github.com/repos/phpspec/prophecy/zipball/3c91bdf81797d725b14cb62906f9a4ce44235972", - "reference": "3c91bdf81797d725b14cb62906f9a4ce44235972", - "shasum": "" - }, - "require": { - "doctrine/instantiator": "^1.0.2", - "php": "^5.3|^7.0", - "phpdocumentor/reflection-docblock": "~2.0", - "sebastian/comparator": "~1.1", - "sebastian/recursion-context": "~1.0" - }, - "require-dev": { - "phpspec/phpspec": "~2.0" - }, - "type": "library", - "extra": { - "branch-alias": { - "dev-master": "1.5.x-dev" - } - }, - "autoload": { - "psr-0": { - "Prophecy\\": "src/" - } - }, - "notification-url": "https://packagist.org/downloads/", - "license": [ - "MIT" - ], - "authors": [ - { - "name": "Konstantin Kudryashov", - "email": "ever.zet@gmail.com", - "homepage": "http://everzet.com" - }, - { - "name": "Marcello Duarte", - "email": "marcello.duarte@gmail.com" - } - ], - "description": "Highly opinionated mocking framework for PHP 5.3+", - "homepage": "https://github.com/phpspec/prophecy", - "keywords": [ - "Double", - "Dummy", - "fake", - "mock", - "spy", - "stub" - ], - "time": "2016-02-15 07:46:21" - }, - { - "name": "phpunit/php-code-coverage", - "version": "3.3.0", - "source": { - "type": "git", - "url": "https://github.com/sebastianbergmann/php-code-coverage.git", - "reference": "fe33716763b604ade4cb442c0794f5bd5ad73004" - }, - "dist": { - "type": "zip", - "url": "https://api.github.com/repos/sebastianbergmann/php-code-coverage/zipball/fe33716763b604ade4cb442c0794f5bd5ad73004", - "reference": "fe33716763b604ade4cb442c0794f5bd5ad73004", - "shasum": "" - }, - "require": { - "php": "^5.6 || ^7.0", - "phpunit/php-file-iterator": "~1.3", - "phpunit/php-text-template": "~1.2", - "phpunit/php-token-stream": "^1.4.2", - "sebastian/code-unit-reverse-lookup": "~1.0", - "sebastian/environment": "^1.3.2", - "sebastian/version": "~1.0|~2.0" - }, - "require-dev": { - "ext-xdebug": ">=2.1.4", - "phpunit/phpunit": "~5" - }, - "suggest": { - "ext-dom": "*", - "ext-xdebug": ">=2.2.1", - "ext-xmlwriter": "*" - }, - "type": "library", - "extra": { - "branch-alias": { - "dev-master": "3.3.x-dev" - } - }, - "autoload": { - "classmap": [ - "src/" - ] - }, - "notification-url": "https://packagist.org/downloads/", - "license": [ - "BSD-3-Clause" - ], - "authors": [ - { - "name": "Sebastian Bergmann", - "email": "sb@sebastian-bergmann.de", - "role": "lead" - } - ], - "description": "Library that provides collection, processing, and rendering functionality for PHP code coverage information.", - "homepage": "https://github.com/sebastianbergmann/php-code-coverage", - "keywords": [ - "coverage", - "testing", - "xunit" - ], - "time": "2016-03-03 08:49:08" - }, - { - "name": "phpunit/php-file-iterator", - "version": "1.4.1", - "source": { - "type": "git", - "url": "https://github.com/sebastianbergmann/php-file-iterator.git", - "reference": "6150bf2c35d3fc379e50c7602b75caceaa39dbf0" - }, - "dist": { - "type": "zip", - "url": "https://api.github.com/repos/sebastianbergmann/php-file-iterator/zipball/6150bf2c35d3fc379e50c7602b75caceaa39dbf0", - "reference": "6150bf2c35d3fc379e50c7602b75caceaa39dbf0", - "shasum": "" - }, - "require": { - "php": ">=5.3.3" - }, - "type": "library", - "extra": { - "branch-alias": { - "dev-master": "1.4.x-dev" - } - }, - "autoload": { - "classmap": [ - "src/" - ] - }, - "notification-url": "https://packagist.org/downloads/", - "license": [ - "BSD-3-Clause" - ], - "authors": [ - { - "name": "Sebastian Bergmann", - "email": "sb@sebastian-bergmann.de", - "role": "lead" - } - ], - "description": "FilterIterator implementation that filters files based on a list of suffixes.", - "homepage": "https://github.com/sebastianbergmann/php-file-iterator/", - "keywords": [ - "filesystem", - "iterator" - ], - "time": "2015-06-21 13:08:43" - }, - { - "name": "phpunit/php-text-template", - "version": "1.2.1", - "source": { - "type": "git", - "url": "https://github.com/sebastianbergmann/php-text-template.git", - "reference": "31f8b717e51d9a2afca6c9f046f5d69fc27c8686" - }, - "dist": { - "type": "zip", - "url": "https://api.github.com/repos/sebastianbergmann/php-text-template/zipball/31f8b717e51d9a2afca6c9f046f5d69fc27c8686", - "reference": "31f8b717e51d9a2afca6c9f046f5d69fc27c8686", - "shasum": "" - }, - "require": { - "php": ">=5.3.3" - }, - "type": "library", - "autoload": { - "classmap": [ - "src/" - ] - }, - "notification-url": "https://packagist.org/downloads/", - "license": [ - "BSD-3-Clause" - ], - "authors": [ - { - "name": "Sebastian Bergmann", - "email": "sebastian@phpunit.de", - "role": "lead" - } - ], - "description": "Simple template engine.", - "homepage": "https://github.com/sebastianbergmann/php-text-template/", - "keywords": [ - "template" - ], - "time": "2015-06-21 13:50:34" - }, - { - "name": "phpunit/php-timer", - "version": "1.0.7", - "source": { - "type": "git", - "url": "https://github.com/sebastianbergmann/php-timer.git", - "reference": "3e82f4e9fc92665fafd9157568e4dcb01d014e5b" - }, - "dist": { - "type": "zip", - "url": "https://api.github.com/repos/sebastianbergmann/php-timer/zipball/3e82f4e9fc92665fafd9157568e4dcb01d014e5b", - "reference": "3e82f4e9fc92665fafd9157568e4dcb01d014e5b", - "shasum": "" - }, - "require": { - "php": ">=5.3.3" - }, - "type": "library", - "autoload": { - "classmap": [ - "src/" - ] - }, - "notification-url": "https://packagist.org/downloads/", - "license": [ - "BSD-3-Clause" - ], - "authors": [ - { - "name": "Sebastian Bergmann", - "email": "sb@sebastian-bergmann.de", - "role": "lead" - } - ], - "description": "Utility class for timing", - "homepage": "https://github.com/sebastianbergmann/php-timer/", - "keywords": [ - "timer" - ], - "time": "2015-06-21 08:01:12" - }, - { - "name": "phpunit/php-token-stream", - "version": "1.4.8", - "source": { - "type": "git", - "url": "https://github.com/sebastianbergmann/php-token-stream.git", - "reference": "3144ae21711fb6cac0b1ab4cbe63b75ce3d4e8da" - }, - "dist": { - "type": "zip", - "url": "https://api.github.com/repos/sebastianbergmann/php-token-stream/zipball/3144ae21711fb6cac0b1ab4cbe63b75ce3d4e8da", - "reference": "3144ae21711fb6cac0b1ab4cbe63b75ce3d4e8da", - "shasum": "" - }, - "require": { - "ext-tokenizer": "*", - "php": ">=5.3.3" - }, - "require-dev": { - "phpunit/phpunit": "~4.2" - }, - "type": "library", - "extra": { - "branch-alias": { - "dev-master": "1.4-dev" - } - }, - "autoload": { - "classmap": [ - "src/" - ] - }, - "notification-url": "https://packagist.org/downloads/", - "license": [ - "BSD-3-Clause" - ], - "authors": [ - { - "name": "Sebastian Bergmann", - "email": "sebastian@phpunit.de" - } - ], - "description": "Wrapper around PHP's tokenizer extension.", - "homepage": "https://github.com/sebastianbergmann/php-token-stream/", - "keywords": [ - "tokenizer" - ], - "time": "2015-09-15 10:49:45" - }, - { - "name": "phpunit/phpunit", - "version": "5.2.12", - "source": { - "type": "git", - "url": "https://github.com/sebastianbergmann/phpunit.git", - "reference": "6f0948bab32270352f97d1913d82a49338dcb0da" - }, - "dist": { - "type": "zip", - "url": "https://api.github.com/repos/sebastianbergmann/phpunit/zipball/6f0948bab32270352f97d1913d82a49338dcb0da", - "reference": "6f0948bab32270352f97d1913d82a49338dcb0da", - "shasum": "" - }, - "require": { - "ext-dom": "*", - "ext-json": "*", - "ext-pcre": "*", - "ext-reflection": "*", - "ext-spl": "*", - "myclabs/deep-copy": "~1.3", - "php": "^5.6 || ^7.0", - "phpspec/prophecy": "^1.3.1", - "phpunit/php-code-coverage": "^3.3.0", - "phpunit/php-file-iterator": "~1.4", - "phpunit/php-text-template": "~1.2", - "phpunit/php-timer": ">=1.0.6", - "phpunit/phpunit-mock-objects": ">=3.0.5", - "sebastian/comparator": "~1.1", - "sebastian/diff": "~1.2", - "sebastian/environment": "~1.3", - "sebastian/exporter": "~1.2", - "sebastian/global-state": "~1.0", - "sebastian/resource-operations": "~1.0", - "sebastian/version": "~1.0|~2.0", - "symfony/yaml": "~2.1|~3.0" - }, - "suggest": { - "phpunit/php-invoker": "~1.1" - }, - "bin": [ - "phpunit" - ], - "type": "library", - "extra": { - "branch-alias": { - "dev-master": "5.2.x-dev" - } - }, - "autoload": { - "classmap": [ - "src/" - ] - }, - "notification-url": "https://packagist.org/downloads/", - "license": [ - "BSD-3-Clause" - ], - "authors": [ - { - "name": "Sebastian Bergmann", - "email": "sebastian@phpunit.de", - "role": "lead" - } - ], - "description": "The PHP Unit Testing framework.", - "homepage": "https://phpunit.de/", - "keywords": [ - "phpunit", - "testing", - "xunit" - ], - "time": "2016-03-15 05:59:58" - }, - { - "name": "phpunit/phpunit-mock-objects", - "version": "3.0.6", - "source": { - "type": "git", - "url": "https://github.com/sebastianbergmann/phpunit-mock-objects.git", - "reference": "49bc700750196c04dd6bc2c4c99cb632b893836b" - }, - "dist": { - "type": "zip", - "url": "https://api.github.com/repos/sebastianbergmann/phpunit-mock-objects/zipball/49bc700750196c04dd6bc2c4c99cb632b893836b", - "reference": "49bc700750196c04dd6bc2c4c99cb632b893836b", - "shasum": "" - }, - "require": { - "doctrine/instantiator": "^1.0.2", - "php": ">=5.6", - "phpunit/php-text-template": "~1.2", - "sebastian/exporter": "~1.2" - }, - "require-dev": { - "phpunit/phpunit": "~5" - }, - "suggest": { - "ext-soap": "*" - }, - "type": "library", - "extra": { - "branch-alias": { - "dev-master": "3.0.x-dev" - } - }, - "autoload": { - "classmap": [ - "src/" - ] - }, - "notification-url": "https://packagist.org/downloads/", - "license": [ - "BSD-3-Clause" - ], - "authors": [ - { - "name": "Sebastian Bergmann", - "email": "sb@sebastian-bergmann.de", - "role": "lead" - } - ], - "description": "Mock Object library for PHPUnit", - "homepage": "https://github.com/sebastianbergmann/phpunit-mock-objects/", - "keywords": [ - "mock", - "xunit" - ], - "time": "2015-12-08 08:47:06" - }, - { - "name": "sebastian/code-unit-reverse-lookup", - "version": "1.0.0", - "source": { - "type": "git", - "url": "https://github.com/sebastianbergmann/code-unit-reverse-lookup.git", - "reference": "c36f5e7cfce482fde5bf8d10d41a53591e0198fe" - }, - "dist": { - "type": "zip", - "url": "https://api.github.com/repos/sebastianbergmann/code-unit-reverse-lookup/zipball/c36f5e7cfce482fde5bf8d10d41a53591e0198fe", - "reference": "c36f5e7cfce482fde5bf8d10d41a53591e0198fe", - "shasum": "" - }, - "require": { - "php": ">=5.6" - }, - "require-dev": { - "phpunit/phpunit": "~5" - }, - "type": "library", - "extra": { - "branch-alias": { - "dev-master": "1.0.x-dev" - } - }, - "autoload": { - "classmap": [ - "src/" - ] - }, - "notification-url": "https://packagist.org/downloads/", - "license": [ - "BSD-3-Clause" - ], - "authors": [ - { - "name": "Sebastian Bergmann", - "email": "sebastian@phpunit.de" - } - ], - "description": "Looks up which function or method a line of code belongs to", - "homepage": "https://github.com/sebastianbergmann/code-unit-reverse-lookup/", - "time": "2016-02-13 06:45:14" - }, - { - "name": "sebastian/comparator", - "version": "1.2.0", - "source": { - "type": "git", - "url": "https://github.com/sebastianbergmann/comparator.git", - "reference": "937efb279bd37a375bcadf584dec0726f84dbf22" - }, - "dist": { - "type": "zip", - "url": "https://api.github.com/repos/sebastianbergmann/comparator/zipball/937efb279bd37a375bcadf584dec0726f84dbf22", - "reference": "937efb279bd37a375bcadf584dec0726f84dbf22", - "shasum": "" - }, - "require": { - "php": ">=5.3.3", - "sebastian/diff": "~1.2", - "sebastian/exporter": "~1.2" - }, - "require-dev": { - "phpunit/phpunit": "~4.4" - }, - "type": "library", - "extra": { - "branch-alias": { - "dev-master": "1.2.x-dev" - } - }, - "autoload": { - "classmap": [ - "src/" - ] - }, - "notification-url": "https://packagist.org/downloads/", - "license": [ - "BSD-3-Clause" - ], - "authors": [ - { - "name": "Jeff Welch", - "email": "whatthejeff@gmail.com" - }, - { - "name": "Volker Dusch", - "email": "github@wallbash.com" - }, - { - "name": "Bernhard Schussek", - "email": "bschussek@2bepublished.at" - }, - { - "name": "Sebastian Bergmann", - "email": "sebastian@phpunit.de" - } - ], - "description": "Provides the functionality to compare PHP values for equality", - "homepage": "http://www.github.com/sebastianbergmann/comparator", - "keywords": [ - "comparator", - "compare", - "equality" - ], - "time": "2015-07-26 15:48:44" - }, - { - "name": "sebastian/diff", - "version": "1.4.1", - "source": { - "type": "git", - "url": "https://github.com/sebastianbergmann/diff.git", - "reference": "13edfd8706462032c2f52b4b862974dd46b71c9e" - }, - "dist": { - "type": "zip", - "url": "https://api.github.com/repos/sebastianbergmann/diff/zipball/13edfd8706462032c2f52b4b862974dd46b71c9e", - "reference": "13edfd8706462032c2f52b4b862974dd46b71c9e", - "shasum": "" - }, - "require": { - "php": ">=5.3.3" - }, - "require-dev": { - "phpunit/phpunit": "~4.8" - }, - "type": "library", - "extra": { - "branch-alias": { - "dev-master": "1.4-dev" - } - }, - "autoload": { - "classmap": [ - "src/" - ] - }, - "notification-url": "https://packagist.org/downloads/", - "license": [ - "BSD-3-Clause" - ], - "authors": [ - { - "name": "Kore Nordmann", - "email": "mail@kore-nordmann.de" - }, - { - "name": "Sebastian Bergmann", - "email": "sebastian@phpunit.de" - } - ], - "description": "Diff implementation", - "homepage": "https://github.com/sebastianbergmann/diff", - "keywords": [ - "diff" - ], - "time": "2015-12-08 07:14:41" - }, - { - "name": "sebastian/environment", - "version": "1.3.5", - "source": { - "type": "git", - "url": "https://github.com/sebastianbergmann/environment.git", - "reference": "dc7a29032cf72b54f36dac15a1ca5b3a1b6029bf" - }, - "dist": { - "type": "zip", - "url": "https://api.github.com/repos/sebastianbergmann/environment/zipball/dc7a29032cf72b54f36dac15a1ca5b3a1b6029bf", - "reference": "dc7a29032cf72b54f36dac15a1ca5b3a1b6029bf", - "shasum": "" - }, - "require": { - "php": ">=5.3.3" - }, - "require-dev": { - "phpunit/phpunit": "~4.4" - }, - "type": "library", - "extra": { - "branch-alias": { - "dev-master": "1.3.x-dev" - } - }, - "autoload": { - "classmap": [ - "src/" - ] - }, - "notification-url": "https://packagist.org/downloads/", - "license": [ - "BSD-3-Clause" - ], - "authors": [ - { - "name": "Sebastian Bergmann", - "email": "sebastian@phpunit.de" - } - ], - "description": "Provides functionality to handle HHVM/PHP environments", - "homepage": "http://www.github.com/sebastianbergmann/environment", - "keywords": [ - "Xdebug", - "environment", - "hhvm" - ], - "time": "2016-02-26 18:40:46" - }, - { - "name": "sebastian/exporter", - "version": "1.2.1", - "source": { - "type": "git", - "url": "https://github.com/sebastianbergmann/exporter.git", - "reference": "7ae5513327cb536431847bcc0c10edba2701064e" - }, - "dist": { - "type": "zip", - "url": "https://api.github.com/repos/sebastianbergmann/exporter/zipball/7ae5513327cb536431847bcc0c10edba2701064e", - "reference": "7ae5513327cb536431847bcc0c10edba2701064e", - "shasum": "" - }, - "require": { - "php": ">=5.3.3", - "sebastian/recursion-context": "~1.0" - }, - "require-dev": { - "phpunit/phpunit": "~4.4" - }, - "type": "library", - "extra": { - "branch-alias": { - "dev-master": "1.2.x-dev" - } - }, - "autoload": { - "classmap": [ - "src/" - ] - }, - "notification-url": "https://packagist.org/downloads/", - "license": [ - "BSD-3-Clause" - ], - "authors": [ - { - "name": "Jeff Welch", - "email": "whatthejeff@gmail.com" - }, - { - "name": "Volker Dusch", - "email": "github@wallbash.com" - }, - { - "name": "Bernhard Schussek", - "email": "bschussek@2bepublished.at" - }, - { - "name": "Sebastian Bergmann", - "email": "sebastian@phpunit.de" - }, - { - "name": "Adam Harvey", - "email": "aharvey@php.net" - } - ], - "description": "Provides the functionality to export PHP variables for visualization", - "homepage": "http://www.github.com/sebastianbergmann/exporter", - "keywords": [ - "export", - "exporter" - ], - "time": "2015-06-21 07:55:53" - }, - { - "name": "sebastian/global-state", - "version": "1.1.1", - "source": { - "type": "git", - "url": "https://github.com/sebastianbergmann/global-state.git", - "reference": "bc37d50fea7d017d3d340f230811c9f1d7280af4" - }, - "dist": { - "type": "zip", - "url": "https://api.github.com/repos/sebastianbergmann/global-state/zipball/bc37d50fea7d017d3d340f230811c9f1d7280af4", - "reference": "bc37d50fea7d017d3d340f230811c9f1d7280af4", - "shasum": "" - }, - "require": { - "php": ">=5.3.3" - }, - "require-dev": { - "phpunit/phpunit": "~4.2" - }, - "suggest": { - "ext-uopz": "*" - }, - "type": "library", - "extra": { - "branch-alias": { - "dev-master": "1.0-dev" - } - }, - "autoload": { - "classmap": [ - "src/" - ] - }, - "notification-url": "https://packagist.org/downloads/", - "license": [ - "BSD-3-Clause" - ], - "authors": [ - { - "name": "Sebastian Bergmann", - "email": "sebastian@phpunit.de" - } - ], - "description": "Snapshotting of global state", - "homepage": "http://www.github.com/sebastianbergmann/global-state", - "keywords": [ - "global state" - ], - "time": "2015-10-12 03:26:01" - }, - { - "name": "sebastian/recursion-context", - "version": "1.0.2", - "source": { - "type": "git", - "url": "https://github.com/sebastianbergmann/recursion-context.git", - "reference": "913401df809e99e4f47b27cdd781f4a258d58791" - }, - "dist": { - "type": "zip", - "url": "https://api.github.com/repos/sebastianbergmann/recursion-context/zipball/913401df809e99e4f47b27cdd781f4a258d58791", - "reference": "913401df809e99e4f47b27cdd781f4a258d58791", - "shasum": "" - }, - "require": { - "php": ">=5.3.3" - }, - "require-dev": { - "phpunit/phpunit": "~4.4" - }, - "type": "library", - "extra": { - "branch-alias": { - "dev-master": "1.0.x-dev" - } - }, - "autoload": { - "classmap": [ - "src/" - ] - }, - "notification-url": "https://packagist.org/downloads/", - "license": [ - "BSD-3-Clause" - ], - "authors": [ - { - "name": "Jeff Welch", - "email": "whatthejeff@gmail.com" - }, - { - "name": "Sebastian Bergmann", - "email": "sebastian@phpunit.de" - }, - { - "name": "Adam Harvey", - "email": "aharvey@php.net" - } - ], - "description": "Provides functionality to recursively process PHP variables", - "homepage": "http://www.github.com/sebastianbergmann/recursion-context", - "time": "2015-11-11 19:50:13" - }, - { - "name": "sebastian/resource-operations", - "version": "1.0.0", - "source": { - "type": "git", - "url": "https://github.com/sebastianbergmann/resource-operations.git", - "reference": "ce990bb21759f94aeafd30209e8cfcdfa8bc3f52" - }, - "dist": { - "type": "zip", - "url": "https://api.github.com/repos/sebastianbergmann/resource-operations/zipball/ce990bb21759f94aeafd30209e8cfcdfa8bc3f52", - "reference": "ce990bb21759f94aeafd30209e8cfcdfa8bc3f52", - "shasum": "" - }, - "require": { - "php": ">=5.6.0" - }, - "type": "library", - "extra": { - "branch-alias": { - "dev-master": "1.0.x-dev" - } - }, - "autoload": { - "classmap": [ - "src/" - ] - }, - "notification-url": "https://packagist.org/downloads/", - "license": [ - "BSD-3-Clause" - ], - "authors": [ - { - "name": "Sebastian Bergmann", - "email": "sebastian@phpunit.de" - } - ], - "description": "Provides a list of PHP built-in functions that operate on resources", - "homepage": "https://www.github.com/sebastianbergmann/resource-operations", - "time": "2015-07-28 20:34:47" - }, - { - "name": "sebastian/version", - "version": "2.0.0", - "source": { - "type": "git", - "url": "https://github.com/sebastianbergmann/version.git", - "reference": "c829badbd8fdf16a0bad8aa7fa7971c029f1b9c5" - }, - "dist": { - "type": "zip", - "url": "https://api.github.com/repos/sebastianbergmann/version/zipball/c829badbd8fdf16a0bad8aa7fa7971c029f1b9c5", - "reference": "c829badbd8fdf16a0bad8aa7fa7971c029f1b9c5", - "shasum": "" - }, - "require": { - "php": ">=5.6" - }, - "type": "library", - "extra": { - "branch-alias": { - "dev-master": "2.0.x-dev" - } - }, - "autoload": { - "classmap": [ - "src/" - ] - }, - "notification-url": "https://packagist.org/downloads/", - "license": [ - "BSD-3-Clause" - ], - "authors": [ - { - "name": "Sebastian Bergmann", - "email": "sebastian@phpunit.de", - "role": "lead" - } - ], - "description": "Library that helps with managing the version number of Git-hosted PHP projects", - "homepage": "https://github.com/sebastianbergmann/version", - "time": "2016-02-04 12:56:52" - }, - { - "name": "symfony/yaml", - "version": "v3.0.3", - "source": { - "type": "git", - "url": "https://github.com/symfony/yaml.git", - "reference": "b5ba64cd67ecd6887f63868fa781ca094bd1377c" - }, - "dist": { - "type": "zip", - "url": "https://api.github.com/repos/symfony/yaml/zipball/b5ba64cd67ecd6887f63868fa781ca094bd1377c", - "reference": "b5ba64cd67ecd6887f63868fa781ca094bd1377c", - "shasum": "" - }, - "require": { - "php": ">=5.5.9" - }, - "type": "library", - "extra": { - "branch-alias": { - "dev-master": "3.0-dev" - } - }, - "autoload": { - "psr-4": { - "Symfony\\Component\\Yaml\\": "" - }, - "exclude-from-classmap": [ - "/Tests/" - ] - }, - "notification-url": "https://packagist.org/downloads/", - "license": [ - "MIT" - ], - "authors": [ - { - "name": "Fabien Potencier", - "email": "fabien@symfony.com" - }, - { - "name": "Symfony Community", - "homepage": "https://symfony.com/contributors" - } - ], - "description": "Symfony Yaml Component", - "homepage": "https://symfony.com", - "time": "2016-02-23 15:16:06" - } - ], - "aliases": [], - "minimum-stability": "stable", - "stability-flags": [], - "prefer-stable": false, - "prefer-lowest": false, - "platform": { - "php": ">=7.0" - }, - "platform-dev": [] -} diff --git a/phpcs.xml b/phpcs.xml new file mode 100644 index 0000000..3a54078 --- /dev/null +++ b/phpcs.xml @@ -0,0 +1,7 @@ + + + src + tests + + + diff --git a/phpstan-baseline.neon b/phpstan-baseline.neon new file mode 100644 index 0000000..2c27d18 --- /dev/null +++ b/phpstan-baseline.neon @@ -0,0 +1,27 @@ +parameters: + ignoreErrors: + - + message: "#^Property Firehed\\\\U2F\\\\ClientData\\:\\:\\$cid_pubkey is never read, only written\\.$#" + count: 1 + path: src/ClientData.php + + - + message: "#^Property Firehed\\\\U2F\\\\ClientData\\:\\:\\$typ is never read, only written\\.$#" + count: 1 + path: src/ClientData.php + + - + message: "#^Method Firehed\\\\U2F\\\\FunctionsTest\\:\\:vectors\\(\\) should return array\\ but returns array\\(array\\('', ''\\), array\\('f', 'Zg'\\), array\\('fo', 'Zm8'\\), array\\('foo', 'Zm9v'\\), array\\('foob', 'Zm9vYg'\\), array\\('fooba', 'Zm9vYmE'\\), array\\('foobar', 'Zm9vYmFy'\\), array\\(string\\|false, 'AA_BB\\-cc'\\)\\)\\.$#" + count: 1 + path: tests/FunctionsTest.php + + - + message: "#^Call to an undefined static method object\\:\\:fromJson\\(\\)\\.$#" + count: 4 + path: tests/ResponseTraitTest.php + + - + message: "#^Method class@anonymous/tests/ResponseTraitTest\\.php\\:18\\:\\:parseResponse\\(\\) has parameter \\$response with no value type specified in iterable type array\\.$#" + count: 1 + path: tests/ResponseTraitTest.php + diff --git a/phpstan.neon b/phpstan.neon new file mode 100644 index 0000000..71f50cc --- /dev/null +++ b/phpstan.neon @@ -0,0 +1,9 @@ +includes: + - phpstan-baseline.neon + - vendor/phpstan/phpstan-phpunit/extension.neon + - vendor/phpstan/phpstan/conf/bleedingEdge.neon +parameters: + level: max + paths: + - src + - tests diff --git a/phpunit.xml b/phpunit.xml new file mode 100644 index 0000000..6d0d482 --- /dev/null +++ b/phpunit.xml @@ -0,0 +1,24 @@ + + + + + tests + + + + + + src + + + + + + diff --git a/src/AppIdTrait.php b/src/AppIdTrait.php index 8a25558..ee38d01 100644 --- a/src/AppIdTrait.php +++ b/src/AppIdTrait.php @@ -5,22 +5,33 @@ trait AppIdTrait { + /** @var string */ private $appId; - public function getAppId(): string { + public function getAppId(): string + { return $this->appId; } - public function setAppId(string $appId): self { + public function setAppId(string $appId): self + { $this->appId = $appId; return $this; } /** - * @return the raw SHA-256 hash of the App ID + * @return string The raw SHA-256 hash of the App ID */ - public function getApplicationParameter(): string { + public function getApplicationParameter(): string + { return hash('sha256', $this->appId, true); } + /** + * @return string The raw SHA-256 hash of the Relying Party ID + */ + public function getRpIdHash(): string + { + return hash('sha256', $this->appId, true); + } } diff --git a/src/AttestationCertificate.php b/src/AttestationCertificate.php new file mode 100644 index 0000000..e0f8a29 --- /dev/null +++ b/src/AttestationCertificate.php @@ -0,0 +1,35 @@ +binary = $binary; + } + + public function getBinary(): string + { + return $this->binary; + } + + public function getPemFormatted(): string + { + $data = base64_encode($this->binary); + $pem = "-----BEGIN CERTIFICATE-----\r\n"; + $pem .= chunk_split($data, 64); + $pem .= "-----END CERTIFICATE-----"; + return $pem; + } + + /** @return array{binary: string} */ + public function __debugInfo(): array + { + return ['binary' => '0x' . bin2hex($this->binary)]; + } +} diff --git a/src/AttestationCertificateInterface.php b/src/AttestationCertificateInterface.php new file mode 100644 index 0000000..38f3f18 --- /dev/null +++ b/src/AttestationCertificateInterface.php @@ -0,0 +1,11 @@ +attest); - } - - // PEM formatted cert - public function getAttestationCertificatePem(): string { - $pem = "-----BEGIN CERTIFICATE-----\r\n"; - $pem .= chunk_split($this->attest, 64); - $pem .= "-----END CERTIFICATE-----"; - return $pem; - } - - public function setAttestationCertificate(string $cert): self { - // In the future, this may make assertions about the cert formatting; - // right now, we're going to leave it be. - $this->attest = base64_encode($cert); - return $this; - } - - public function verifyIssuerAgainstTrustedCAs(array $trusted_cas): bool { - $result = openssl_x509_checkpurpose( - $this->getAttestationCertificatePem(), - \X509_PURPOSE_ANY, - $trusted_cas); - if ($result !== true) { - throw new SecurityException(SecurityException::NO_TRUSTED_CA); - } - return $result; - } - -} diff --git a/src/ChallengeProvider.php b/src/ChallengeProvider.php index 23df621..334b117 100644 --- a/src/ChallengeProvider.php +++ b/src/ChallengeProvider.php @@ -5,7 +5,5 @@ interface ChallengeProvider { - public function getChallenge(): string; - } diff --git a/src/ChallengeTrait.php b/src/ChallengeTrait.php index a0bdb2d..767cb23 100644 --- a/src/ChallengeTrait.php +++ b/src/ChallengeTrait.php @@ -4,14 +4,17 @@ trait ChallengeTrait { + /** @var string */ private $challenge; // B64-websafe value (at no point is the binary version used) - public function getChallenge(): string { - return ($this->challenge); + public function getChallenge(): string + { + return $this->challenge; } - public function setChallenge(string $challenge): self { + public function setChallenge(string $challenge): self + { // TODO: make immutable $this->challenge = $challenge; return $this; diff --git a/src/ClientData.php b/src/ClientData.php index 027269b..6034105 100644 --- a/src/ClientData.php +++ b/src/ClientData.php @@ -3,18 +3,26 @@ namespace Firehed\U2F; -use JsonSerializable; use Firehed\U2F\InvalidDataException as IDE; -class ClientData implements JsonSerializable, ChallengeProvider +class ClientData { use ChallengeTrait; + /** @var string */ + private $originalJson; + + /** @var string */ private $cid_pubkey; + + /** @var string */ private $origin; + + /** @var string */ private $typ; - public static function fromJson(string $json) { + public static function fromJson(string $json): ClientData + { $data = json_decode($json, true); if (json_last_error() !== \JSON_ERROR_NONE) { throw new IDE(IDE::MALFORMED_DATA, 'json'); @@ -23,24 +31,34 @@ public static function fromJson(string $json) { $ret->setType($ret->validateKey('typ', $data)); $ret->setChallenge($ret->validateKey('challenge', $data)); $ret->origin = $ret->validateKey('origin', $data); - $ret->cid_pubkey = $ret->validateKey('cid_pubkey', $data); + // This field is optional + if (isset($data['cid_pubkey'])) { + $ret->cid_pubkey = $data['cid_pubkey']; + } + $ret->originalJson = $json; return $ret; } + public function getApplicationParameter(): string + { + return hash('sha256', $this->origin, true); + } + /** * Checks the 'typ' field against the allowed types in the U2F spec (sec. * 7.1) - * @param $type the 'typ' value + * @param string $type the 'typ' value * @return $this * @throws InvalidDataException if a non-conforming value is provided */ - private function setType(string $type): self { + private function setType(string $type): self + { switch ($type) { - case 'navigator.id.getAssertion': // fall through - case 'navigator.id.finishEnrollment': - break; - default: - throw new IDE(IDE::MALFORMED_DATA, 'typ'); + case 'navigator.id.getAssertion': // fall through + case 'navigator.id.finishEnrollment': + break; + default: + throw new IDE(IDE::MALFORMED_DATA, 'typ'); } $this->typ = $type; return $this; @@ -49,12 +67,13 @@ private function setType(string $type): self { /** * Checks for the presence of $key in $data. Returns the value if found, * throws an InvalidDataException if missing - * @param $key The array key to check - * @param $data The array to check in - * @return mixed The data, if present + * @param string $key The array key to check + * @param array $data The array to check in + * @return string The data, if present * @throws InvalidDataException if not prsent */ - private function validateKey(string $key, array $data) { + private function validateKey(string $key, array $data): string + { if (!array_key_exists($key, $data)) { throw new IDE(IDE::MISSING_KEY, $key); } @@ -62,18 +81,8 @@ private function validateKey(string $key, array $data) { } // Returns the SHA256 hash of this object per the raw message formats spec - public function getChallengeParameter(): string { - $json = json_encode($this, \JSON_UNESCAPED_SLASHES); - return hash('sha256', $json, true); - } - - public function jsonSerialize() { - return [ - 'typ' => $this->typ, - 'challenge' => $this->getChallenge(), - 'origin' => $this->origin, - 'cid_pubkey' => $this->cid_pubkey, - ]; + public function getChallengeParameter(): string + { + return hash('sha256', $this->originalJson, true); } - } diff --git a/src/ClientErrorException.php b/src/ClientErrorException.php index 9944045..a4f082c 100644 --- a/src/ClientErrorException.php +++ b/src/ClientErrorException.php @@ -2,11 +2,13 @@ declare(strict_types=1); namespace Firehed\U2F; + use Exception; class ClientErrorException extends Exception { - public function __construct(int $code) { + public function __construct(int $code) + { parent::__construct(ClientError::MESSAGES[$code] ?? '', $code); } } diff --git a/src/ECPublicKeyTrait.php b/src/ECPublicKey.php similarity index 50% rename from src/ECPublicKeyTrait.php rename to src/ECPublicKey.php index ee5f0a3..2ee1755 100644 --- a/src/ECPublicKeyTrait.php +++ b/src/ECPublicKey.php @@ -2,27 +2,46 @@ declare(strict_types=1); namespace Firehed\U2F; -use Firehed\U2F\InvalidDataException as IDE; -trait ECPublicKeyTrait +class ECPublicKey implements PublicKeyInterface { - // Stored base64-encoded - private $pubKey = ''; + /** @var string (binary) */ + private $binary = ''; - // Binary string of public key - public function getPublicKey(): string { - return base64_decode($this->pubKey); + public function __construct(string $key) + { + // RFC5480 2.2 - must be uncompressed value + if ($key[0] !== "\x04") { + throw new InvalidDataException( + InvalidDataException::MALFORMED_DATA, + 'public key: first byte not x04 (uncompressed)' + ); + } + if (strlen($key) !== 65) { + throw new InvalidDataException( + InvalidDataException::PUBLIC_KEY_LENGTH, + '65' + ); + } + $this->binary = $key; + } + + /** + * @return string The decoded public key. + */ + public function getBinary(): string + { + return $this->binary; } // Prepends the pubkey format headers and builds a pem file from the raw // public key component - public function getPublicKeyPem(): string { - $key = $this->getPublicKey(); - + public function getPemFormatted(): string + { // Described in RFC 5480 // Just use an OID calculator to figure out *that* encoding $der = hex2bin( - '3059' // SEQUENCE, length 89 + '3059' // SEQUENCE, length 89 .'3013' // SEQUENCE, length 19 .'0607' // OID, length 7 .'2a8648ce3d0201' // 1.2.840.10045.2.1 = EC Public Key @@ -31,25 +50,20 @@ public function getPublicKeyPem(): string { .'0342' // BIT STRING, length 66 .'00' // prepend with NUL - pubkey will follow ); + $der .= $this->binary; - $der .= $key; $pem = "-----BEGIN PUBLIC KEY-----\r\n"; $pem .= chunk_split(base64_encode($der), 64); $pem .= "-----END PUBLIC KEY-----"; return $pem; } - public function setPublicKey(string $key): self { - // RFC5480 2.2 - must be uncompressed value - if ($key[0] !== "\x04") { - throw new IDE(IDE::MALFORMED_DATA, - 'public key: first byte not x04 (uncompressed)'); - } - if (strlen($key) !== 65) { - throw new IDE(IDE::PUBLIC_KEY_LENGTH, '65'); - } - $this->pubKey = base64_encode($key); - return $this; + /** @return array{x: string, y: string} */ + public function __debugInfo(): array + { + return [ + 'x' => '0x' . bin2hex(substr($this->binary, 1, 32)), + 'y' => '0x' . bin2hex(substr($this->binary, 33)), + ]; } - } diff --git a/src/InvalidDataException.php b/src/InvalidDataException.php index 948ca1d..24d3544 100644 --- a/src/InvalidDataException.php +++ b/src/InvalidDataException.php @@ -2,6 +2,7 @@ declare(strict_types=1); namespace Firehed\U2F; + use Exception; class InvalidDataException extends Exception @@ -16,11 +17,11 @@ class InvalidDataException extends Exception self::PUBLIC_KEY_LENGTH => 'Public key length invalid, must be %s bytes', ]; - public function __construct(int $code, string ...$args) { + public function __construct(int $code, string ...$args) + { $format = self::MESSAGES[$code] ?? 'Default message'; $message = sprintf($format, ...$args); parent::__construct($message, $code); } - } diff --git a/src/KeyHandleInterface.php b/src/KeyHandleInterface.php new file mode 100644 index 0000000..bef8c67 --- /dev/null +++ b/src/KeyHandleInterface.php @@ -0,0 +1,13 @@ +keyHandle); + public function getKeyHandleBinary(): string + { + return $this->keyHandle; } // B64-websafe value - public function getKeyHandleWeb(): string { + public function getKeyHandleWeb(): string + { return toBase64Web($this->getKeyHandleBinary()); } // Binary value - public function setKeyHandle(string $keyHandle): self { + public function setKeyHandle(string $keyHandle): self + { // TODO: make immutable - $this->keyHandle = base64_encode($keyHandle); + $this->keyHandle = $keyHandle; return $this; } - } diff --git a/src/LoginResponseInterface.php b/src/LoginResponseInterface.php new file mode 100644 index 0000000..f514cb3 --- /dev/null +++ b/src/LoginResponseInterface.php @@ -0,0 +1,15 @@ + $this->version, "challenge" => $this->getChallenge(), diff --git a/src/RegisterResponse.php b/src/RegisterResponse.php index 4fda0d3..c417008 100644 --- a/src/RegisterResponse.php +++ b/src/RegisterResponse.php @@ -2,15 +2,30 @@ declare(strict_types=1); namespace Firehed\U2F; + use Firehed\U2F\InvalidDataException as IDE; -class RegisterResponse +class RegisterResponse implements RegistrationResponseInterface { - use AttestationCertificateTrait; - use ECPublicKeyTrait; use ResponseTrait; - protected function parseResponse(array $response): self { + /** @var AttestationCertificate */ + private $cert; + + /** @var PublicKeyInterface */ + private $pubKey; + + /** + * @param array{ + * registrationData: string, + * version: string, + * chalenge: string, + * appId: string, + * clientData: string, + * } $response + */ + protected function parseResponse(array $response): self + { $this->validateKeyInArray('registrationData', $response); // Binary string as defined by // U2F 1.0 Raw Message Format Sec. 4.3 @@ -19,20 +34,24 @@ protected function parseResponse(array $response): self { // Basic fixed length check if (strlen($regData) < 67) { - throw new IDE(IDE::MALFORMED_DATA, - 'registrationData is missing information'); + throw new IDE( + IDE::MALFORMED_DATA, + 'registrationData is missing information' + ); } $offset = 0; // Number of bytes read so far (think fread/fseek) $reserved = ord($regData[$offset]); if ($reserved !== 5) { - throw new IDE(IDE::MALFORMED_DATA, - 'reserved byte'); + throw new IDE( + IDE::MALFORMED_DATA, + 'reserved byte' + ); } $offset += 1; - $this->setPublicKey(substr($regData, $offset, 65)); + $this->pubKey = new ECPublicKey(substr($regData, $offset, 65)); $offset += 65; $keyHandleLength = ord($regData[$offset]); @@ -40,8 +59,10 @@ protected function parseResponse(array $response): self { // Dynamic length check through key handle if (strlen($regData) < $offset+$keyHandleLength) { - throw new IDE(IDE::MALFORMED_DATA, - 'key handle length'); + throw new IDE( + IDE::MALFORMED_DATA, + 'key handle length' + ); } $this->setKeyHandle(substr($regData, $offset, $keyHandleLength)); $offset += $keyHandleLength; @@ -59,16 +80,20 @@ protected function parseResponse(array $response): self { $remain = substr($regData, $offset); $b0 = ord($remain[0]); if (($b0 & 0x1F) != 0x10) { - throw new IDE(IDE::MALFORMED_DATA, - 'starting byte of attestation certificate'); + throw new IDE( + IDE::MALFORMED_DATA, + 'starting byte of attestation certificate' + ); } $length = ord($remain[1]); if (($length & 0x80) == 0x80) { $needed = $length ^ 0x80; if ($needed > 4) { // This would be a >4GB cert, reject it out of hand - throw new IDE(IDE::MALFORMED_DATA, - 'certificate length'); + throw new IDE( + IDE::MALFORMED_DATA, + 'certificate length' + ); } $bytes = 0; // Start 2 bytes in, for SEQUENCE and its LENGTH @@ -83,10 +108,13 @@ protected function parseResponse(array $response): self { // data, in case a malformed cert was provided to trigger an overflow // during parsing if ($length + $offset > strlen($regData)) { - throw new IDE(IDE::MALFORMED_DATA, - 'certificate and sigature length'); + throw new IDE( + IDE::MALFORMED_DATA, + 'certificate and sigature length' + ); } - $this->setAttestationCertificate(substr($regData, $offset, $length)); + $cert = new AttestationCertificate(substr($regData, $offset, $length)); + $this->cert = $cert; $offset += $length; // All remaining data is the signature @@ -95,4 +123,36 @@ protected function parseResponse(array $response): self { return $this; } + public function getSignedData(): string + { + // https://fidoalliance.org/specs/fido-u2f-v1.0-nfc-bt-amendment-20150514/fido-u2f-raw-message-formats.html#fig-authentication-request-message + return sprintf( + '%s%s%s%s%s', + chr(0), + $this->clientData->getApplicationParameter(), + $this->clientData->getChallengeParameter(), + $this->getKeyHandleBinary(), + $this->pubKey->getBinary() + ); + } + + public function getRpIdHash(): string + { + return $this->clientData->getApplicationParameter(); + } + + public function getAttestationCertificate(): AttestationCertificateInterface + { + return $this->cert; + } + + public function getChallenge(): string + { + return $this->clientData->getChallenge(); + } + + public function getPublicKey(): PublicKeyInterface + { + return $this->pubKey; + } } diff --git a/src/Registration.php b/src/Registration.php index 546c6d1..4eaca59 100644 --- a/src/Registration.php +++ b/src/Registration.php @@ -2,20 +2,40 @@ declare(strict_types=1); namespace Firehed\U2F; + use OutOfBoundsException; -class Registration +class Registration implements RegistrationInterface { - use AttestationCertificateTrait; - use ECPublicKeyTrait; use KeyHandleTrait; + /** @var AttestationCertificateInterface */ + private $cert; + + /** @var int */ private $counter = -1; - public function getCounter(): int { + /** @var PublicKeyInterface */ + private $publicKey; + + public function getAttestationCertificate(): AttestationCertificateInterface + { + return $this->cert; + } + + public function getCounter(): int + { return $this->counter; } - public function setCounter(int $counter): self { + + public function setAttestationCertificate(AttestationCertificateInterface $cert): self + { + $this->cert = $cert; + return $this; + } + + public function setCounter(int $counter): self + { if ($counter < 0) { throw new OutOfBoundsException('Counter may not be negative'); } @@ -23,4 +43,36 @@ public function setCounter(int $counter): self { return $this; } + public function getPublicKey(): PublicKeyInterface + { + return $this->publicKey; + } + + public function setPublicKey(PublicKeyInterface $publicKey): self + { + $this->publicKey = $publicKey; + return $this; + } + + /** + * @return array{ + * cert: AttestationCertificateInterface, + * counter: int, + * publicKey: PublicKeyInterface, + * keyHandle: string, + * } + */ + public function __debugInfo(): array + { + $hex = function (string $binary): string { + return '0x' . bin2hex($binary); + }; + + return [ + 'cert' => $this->cert, + 'counter' => $this->counter, + 'publicKey' => $this->publicKey, + 'keyHandle' => $hex($this->keyHandle), + ]; + } } diff --git a/src/RegistrationInterface.php b/src/RegistrationInterface.php new file mode 100644 index 0000000..df3557d --- /dev/null +++ b/src/RegistrationInterface.php @@ -0,0 +1,26 @@ +signature); - } + /** @var string (binary) */ + private $signature = ''; - public function getClientData(): ClientData { - return $this->clientData; + public function getSignature(): string + { + return $this->signature; } - protected function setSignature(string $signature): self { - $this->signature = base64_encode($signature); + protected function setSignature(string $signature): self + { + $this->signature = $signature; return $this; } - public static function fromJson(string $json): self { + public static function fromJson(string $json): self + { $data = json_decode($json, true); if (json_last_error() !== \JSON_ERROR_NONE) { throw new IDE(IDE::MALFORMED_DATA, 'JSON'); @@ -44,7 +44,9 @@ public static function fromJson(string $json): self { abstract protected function parseResponse(array $response): self; - private function validateKeyInArray(string $key, array $data): bool { + /** @param array $data */ + private function validateKeyInArray(string $key, array $data): bool + { if (!isset($data[$key])) { throw new IDE(IDE::MISSING_KEY, $key); } @@ -53,5 +55,4 @@ private function validateKeyInArray(string $key, array $data): bool { } return true; } - } diff --git a/src/SecurityException.php b/src/SecurityException.php index 01f9ed1..2d2ed73 100644 --- a/src/SecurityException.php +++ b/src/SecurityException.php @@ -2,6 +2,7 @@ declare(strict_types=1); namespace Firehed\U2F; + use Exception; class SecurityException extends Exception @@ -11,17 +12,23 @@ class SecurityException extends Exception const CHALLENGE_MISMATCH = 3; const KEY_HANDLE_UNRECOGNIZED = 4; const NO_TRUSTED_CA = 5; + const WRONG_RELYING_PARTY = 6; const MESSAGES = [ self::SIGNATURE_INVALID => 'Signature verification failed', - self::COUNTER_USED => 'Response counter value is too low, indicating a possible replay attack or cloned token. It is also possible but unlikely that the token\'s internal counter wrapped around. This token should be invalidated or flagged for review.', + self::COUNTER_USED => + 'Response counter value is too low, indicating a possible replay '. + 'attack or cloned token. It is also possible but unlikely that '. + 'the token\'s internal counter wrapped around. This token should '. + 'be invalidated or flagged for review.', self::CHALLENGE_MISMATCH => 'Response challenge does not match request', self::KEY_HANDLE_UNRECOGNIZED => 'Key handle has not been registered', self::NO_TRUSTED_CA => 'The attestation certificate was not signed by any trusted Certificate Authority', + self::WRONG_RELYING_PARTY => 'Relying party invalid for this server', ]; - public function __construct(int $code) { + public function __construct(int $code) + { parent::__construct(self::MESSAGES[$code] ?? '', $code); } - } diff --git a/src/Server.php b/src/Server.php index d3e892e..a3274df 100644 --- a/src/Server.php +++ b/src/Server.php @@ -5,77 +5,104 @@ use BadMethodCallException; use Firehed\U2F\SecurityException as SE; +use RuntimeException; class Server { - use AppIdTrait; /** * Holds a list of paths to PEM-formatted CA certificates. Unless * verification has been explicitly disabled with `disableCAVerification()`, - * the Attestation Certificate in the `RegisterResponse` will be validated - * against the provided CAs. + * the Attestation Certificate in the `RegistrationResponseInterface` will + * be validated against the provided CAs. * * This means that you *must* either a) provide a list of trusted * certificates, or b) explicitly disable verifiation. By default, it will * attempt to validate against an empty list which will always fail. This * is by design. + * + * @var string[] */ private $trustedCAs = []; /** * Indicates whether to verify against `$trustedCAs`. Must be explicitly * disabled with `disableCAVerification()`. + * + * @var bool */ private $verifyCA = true; /** * Holds a RegisterRequest used by `register()`, which contains the * challenge in the signature. + * + * @var ?RegisterRequest */ private $registerRequest; /** * Holds Registrations that were previously established by the * authenticating party during `authenticate()` + * + * @var RegistrationInterface[] */ private $registrations = []; /** * Holds SignRequests used by `authenticate` which contain the challenge * that's part of the signed response. + * + * @var SignRequest[] */ private $signRequests = []; + public function __construct() + { + $overload = ini_get('mbstring.func_overload'); + // @codeCoverageIgnoreStart + if ($overload > 0) { + throw new RuntimeException( + 'The deprecated "mbstring.func_overload" directive must be disabled' + ); + } + // @codeCoverageIgnoreEnd + } /** - * This method authenticates a `SignResponse` against outstanding - * `Registrations` and their corresponding `SignRequest`s. If the - * response's signature validates and the counter hasn't done anything - * strange, the Registration will be returned with an updated counter - * value, which *must* be persisted for the next authentication. If any - * verification component fails, a `SE` will be thrown. + * This method authenticates a `LoginResponseInterface` against outstanding + * registrations and their corresponding `SignRequest`s. If the response's + * signature validates and the counter hasn't done anything strange, the + * registration will be returned with an updated counter value, which *must* + * be persisted for the next authentication. If any verification component + * fails, a `SE` will be thrown. * - * @param SignResponse the parsed response from the user - * @return Registration if authentication succeeds + * @param LoginResponseInterface $response the parsed response from the user + * @return RegistrationInterface if authentication succeeds * @throws SE if authentication fails * @throws BadMethodCallException if a precondition is not met */ - public function authenticate(SignResponse $response): Registration { + public function authenticate(LoginResponseInterface $response): RegistrationInterface + { if (!$this->registrations) { throw new BadMethodCallException( - 'Before calling authenticate(), provide `Registration`s with '. - 'setRegistrations()'); + 'Before calling authenticate(), provide objects implementing'. + 'RegistrationInterface with setRegistrations()' + ); } if (!$this->signRequests) { throw new BadMethodCallException( 'Before calling authenticate(), provide `SignRequest`s with '. - 'setSignRequests()'); + 'setSignRequests()' + ); } // Search for the registration to use based on the Key Handle - $registration = $this->findObjectWithKeyHandle($this->registrations, - $response->getKeyHandleBinary()); + /** @var ?Registration */ + $registration = $this->findObjectWithKeyHandle( + $this->registrations, + $response->getKeyHandleBinary() + ); if (!$registration) { // This would suggest either some sort of forgery attempt or // a hilariously-broken token responding to handles it doesn't @@ -84,8 +111,10 @@ public function authenticate(SignResponse $response): Registration { } // Search for the Signing Request to use based on the Key Handle - $request = $this->findObjectWithKeyHandle($this->signRequests, - $registration->getKeyHandleBinary()); + $request = $this->findObjectWithKeyHandle( + $this->signRequests, + $registration->getKeyHandleBinary() + ); if (!$request) { // Similar to above, there is a bizarre mismatch between the known // possible sign requests and the key handle determined above. This @@ -98,27 +127,15 @@ public function authenticate(SignResponse $response): Registration { // match the one in the signing request, the client signed the // wrong thing. This could possibly be an attempt at a replay // attack. - $this->validateChallenge($response->getClientData(), $request); - - $pem = $registration->getPublicKeyPem(); - - // U2F Spec: - // https://fidoalliance.org/specs/fido-u2f-v1.0-nfc-bt-amendment-20150514/fido-u2f-raw-message-formats.html#authentication-response-message-success - $to_verify = sprintf('%s%s%s%s', - $request->getApplicationParameter(), - chr($response->getUserPresenceByte()), - pack('N', $response->getCounter()), - // Note: Spec says this should be from the request, but that's not - // actually available via the JS API. Because we assert the - // challenge *value* from the Client Data matches the trusted one - // from the SignRequest and that value is included in the Challenge - // Parameter, this is safe unless/until SHA-256 is broken. - $response->getClientData()->getChallengeParameter() - ); + $this->validateChallenge($request, $response); + + $pem = $registration->getPublicKey()->getPemFormatted(); + + $toVerify = $response->getSignedData(); // Signature must validate against $sig_check = openssl_verify( - $to_verify, + $toVerify, $response->getSignature(), $pem, \OPENSSL_ALGO_SHA256 @@ -162,51 +179,44 @@ public function authenticate(SignResponse $response): Registration { // again. There's no perfect way to handle this since return (new Registration()) - ->setAttestationCertificate($registration->getAttestationCertificateBinary()) + ->setAttestationCertificate($registration->getAttestationCertificate()) ->setKeyHandle($registration->getKeyHandleBinary()) ->setPublicKey($registration->getPublicKey()) ->setCounter($response->getCounter()); - return true; } /** - * This method authenticates a RegisterResponse against its corresponding - * RegisterRequest by verifying the certificate and signature. If valid, it - * returns a Registration object; if not, a SE will be - * thrown and attempt to register the key must be aborted. + * This method authenticates a RegistrationResponseInterface against its + * corresponding RegisterRequest by verifying the certificate and signature. + * If valid, it returns a registration; if not, a SE will be thrown and + * attempt to register the key must be aborted. * - * @param The response to verify - * @return Registration if the response is proven authentic + * @param RegistrationResponseInterface $response The response to verify + * @return RegistrationInterface if the response is proven authentic * @throws SE if the response cannot be proven authentic * @throws BadMethodCallException if a precondition is not met */ - public function register(RegisterResponse $resp): Registration { + public function register(RegistrationResponseInterface $response): RegistrationInterface + { if (!$this->registerRequest) { throw new BadMethodCallException( 'Before calling register(), provide a RegisterRequest '. - 'with setRegisterRequest()'); + 'with setRegisterRequest()' + ); } - $this->validateChallenge($resp->getClientData(), $this->registerRequest); - // Check the Application Parameter? - - // https://fidoalliance.org/specs/fido-u2f-v1.0-nfc-bt-amendment-20150514/fido-u2f-raw-message-formats.html#registration-response-message-success - $signed_data = sprintf('%s%s%s%s%s', - chr(0), - $this->registerRequest->getApplicationParameter(), - $resp->getClientData()->getChallengeParameter(), - $resp->getKeyHandleBinary(), - $resp->getPublicKey() - ); + $this->validateChallenge($this->registerRequest, $response); + // Check the Application Parameter + $this->validateRelyingParty($response->getRpIdHash()); - $pem = $resp->getAttestationCertificatePem(); if ($this->verifyCA) { - $resp->verifyIssuerAgainstTrustedCAs($this->trustedCAs); + $this->verifyAttestationCertAgainstTrustedCAs($response); } // Signature must validate against device issuer's public key + $pem = $response->getAttestationCertificate()->getPemFormatted(); $sig_check = openssl_verify( - $signed_data, - $resp->getSignature(), + $response->getSignedData(), + $response->getSignature(), $pem, \OPENSSL_ALGO_SHA256 ); @@ -215,10 +225,10 @@ public function register(RegisterResponse $resp): Registration { } return (new Registration()) - ->setAttestationCertificate($resp->getAttestationCertificateBinary()) + ->setAttestationCertificate($response->getAttestationCertificate()) ->setCounter(0) // The response does not include this - ->setKeyHandle($resp->getKeyHandleBinary()) - ->setPublicKey($resp->getPublicKey()); + ->setKeyHandle($response->getKeyHandleBinary()) + ->setPublicKey($response->getPublicKey()); } /** @@ -231,7 +241,8 @@ public function register(RegisterResponse $resp): Registration { * * @return self */ - public function disableCAVerification(): self { + public function disableCAVerification(): self + { $this->verifyCA = false; return $this; } @@ -243,10 +254,11 @@ public function disableCAVerification(): self { * This method or disableCAVerification must be called before register() or * a SecurityException will always be thrown. * - * @param array A list of file paths to device issuer CA certs + * @param string[] $CAs A list of file paths to device issuer CA certs * @return self */ - public function setTrustedCAs(array $CAs): self { + public function setTrustedCAs(array $CAs): self + { $this->verifyCA = true; $this->trustedCAs = $CAs; return $this; @@ -256,22 +268,26 @@ public function setTrustedCAs(array $CAs): self { * Provide the previously-generated RegisterRequest to be used when * verifying a RegisterResponse during register() * - * @param RegisterRequest + * @param RegisterRequest $request * @return self */ - public function setRegisterRequest(RegisterRequest $request): self { + public function setRegisterRequest(RegisterRequest $request): self + { $this->registerRequest = $request; return $this; } /** - * Provide a user's existing Registrations to be used during authentication + * Provide a user's existing registration to be used during + * authentication * - * @param array + * @param RegistrationInterface[] $registrations * @return self */ - public function setRegistrations(array $registrations): self { - array_map(function(Registration $r){}, $registrations); // type check + public function setRegistrations(array $registrations): self + { + array_map(function (RegistrationInterface $r) { + }, $registrations); // type check $this->registrations = $registrations; return $this; } @@ -281,11 +297,13 @@ public function setRegistrations(array $registrations): self { * existing Registrations, of of which should be signed and will be * verified during authenticate() * - * @param array + * @param SignRequest[] $signRequests * @return self */ - public function setSignRequests(array $signRequests): self { - array_map(function(SignRequest $s){}, $signRequests); // type check + public function setSignRequests(array $signRequests): self + { + array_map(function (SignRequest $s) { + }, $signRequests); // type check $this->signRequests = $signRequests; return $this; } @@ -296,20 +314,22 @@ public function setSignRequests(array $signRequests): self { * * @return RegisterRequest */ - public function generateRegisterRequest(): RegisterRequest { + public function generateRegisterRequest(): RegisterRequest + { return (new RegisterRequest()) ->setAppId($this->getAppId()) ->setChallenge($this->generateChallenge()); } /** - * Creates a new SignRequest for an existing Registration for an + * Creates a new SignRequest for an existing registration for an * authenticating user, used by the `u2f.sign` API. * - * @param Registration one of the user's existing Registrations + * @param RegistrationInterface $reg one of the user's existing Registrations * @return SignRequest */ - public function generateSignRequest(Registration $reg): SignRequest { + public function generateSignRequest(RegistrationInterface $reg): SignRequest + { return (new SignRequest()) ->setAppId($this->getAppId()) ->setChallenge($this->generateChallenge()) @@ -317,12 +337,13 @@ public function generateSignRequest(Registration $reg): SignRequest { } /** - * Wraps generateSignRequest for multiple Registrations + * Wraps generateSignRequest for multiple registrations * - * @param array - * @return array + * @param RegistrationInterface[] $registrations + * @return SignRequest[] */ - public function generateSignRequests(array $registrations): array { + public function generateSignRequests(array $registrations): array + { return array_values(array_map([$this, 'generateSignRequest'], $registrations)); } @@ -331,10 +352,12 @@ public function generateSignRequests(array $registrations): array { * key handle value. If one is found, it is returned; if not, this returns * null. * - * @param array<> haystack to search - * @param string key handle to find in haystack - * @return mixed element from haystack - * @return null if no element matches + * @template T of KeyHandleInterface + * + * @param T[] $objects haystack to search + * @param string $keyHandle key handle to find in haystack + * + * @return ?T element from haystack if match found, otherwise null */ private function findObjectWithKeyHandle( array $objects, @@ -353,25 +376,32 @@ private function findObjectWithKeyHandle( * * @return string */ - private function generateChallenge(): string { + private function generateChallenge(): string + { // FIDO Alliance spec suggests a minimum of 8 random bytes return toBase64Web(\random_bytes(16)); } + private function validateRelyingParty(string $rpIdHash): void + { + // Note: this is a bit delicate at the moment, since different + // protocols have different rules around the handling of Relying Party + // verification. Expect this to be revised. + if (!hash_equals($this->getRpIdHash(), $rpIdHash)) { + throw new SE(SE::WRONG_RELYING_PARTY); + } + } /** * Compares the Challenge value from a known source against the * user-provided value. A mismatch will throw a SE. Future * versions may also enforce a timing window. * - * @param ChallengeProvider source of known challenge - * @param ChallengeProvider user-provided value - * @return true on success + * @param ChallengeProvider $from source of known challenge + * @param ChallengeProvider $to user-provided value * @throws SE on failure */ - private function validateChallenge( - ChallengeProvider $from, - ChallengeProvider $to - ): bool { + private function validateChallenge(ChallengeProvider $from, ChallengeProvider $to): void + { // Note: strictly speaking, this shouldn't even be targetable as // a timing attack. However, this opts to be proactive, and also // ensures that no weird PHP-isms in string handling cause mismatched @@ -379,7 +409,27 @@ private function validateChallenge( if (!hash_equals($from->getChallenge(), $to->getChallenge())) { throw new SE(SE::CHALLENGE_MISMATCH); } - // TOOD: generate and compare timestamps - return true; + } + + /** + * Asserts that the attestation cert provided by the registration is issued + * by the set of trusted CAs. + * + * @param RegistrationResponseInterface $response The response to validate + * @throws SecurityException upon failure + * @return void + */ + private function verifyAttestationCertAgainstTrustedCAs(RegistrationResponseInterface $response): void + { + $pem = $response->getAttestationCertificate()->getPemFormatted(); + + $result = openssl_x509_checkpurpose( + $pem, + \X509_PURPOSE_ANY, + $this->trustedCAs + ); + if ($result !== true) { + throw new SE(SE::NO_TRUSTED_CA); + } } } diff --git a/src/SignRequest.php b/src/SignRequest.php index 4dd3f3c..26509f7 100644 --- a/src/SignRequest.php +++ b/src/SignRequest.php @@ -4,14 +4,15 @@ use JsonSerializable; -class SignRequest implements JsonSerializable, ChallengeProvider +class SignRequest implements JsonSerializable, ChallengeProvider, KeyHandleInterface { use AppIdTrait; use ChallengeTrait; use KeyHandleTrait; use VersionTrait; - public function jsonSerialize() { + public function jsonSerialize() + { return [ "version" => $this->version, "challenge" => $this->getChallenge(), @@ -19,5 +20,4 @@ public function jsonSerialize() { "appId" => $this->getAppId(), ]; } - } diff --git a/src/SignResponse.php b/src/SignResponse.php index e28c14b..ecf2a04 100644 --- a/src/SignResponse.php +++ b/src/SignResponse.php @@ -5,22 +5,54 @@ use Firehed\U2F\InvalidDataException as IDE; -class SignResponse +class SignResponse implements LoginResponseInterface { use ResponseTrait; // Decoded SignatureData + /** @var int */ private $counter = -1; + + /** @var int */ private $user_presence = 0; - public function getCounter(): int { + public function getCounter(): int + { return $this->counter; } - public function getUserPresenceByte(): int { + + public function getUserPresenceByte(): int + { return $this->user_presence; } - protected function parseResponse(array $response): self { + public function getSignedData(): string + { + // U2F Spec: + // https://fidoalliance.org/specs/fido-u2f-v1.0-nfc-bt-amendment-20150514/fido-u2f-raw-message-formats.html#authentication-response-message-success + return sprintf( + '%s%s%s%s', + $this->clientData->getApplicationParameter(), + chr($this->getUserPresenceByte()), + pack('N', $this->getCounter()), + // Note: Spec says this should be from the request, but that's not + // actually available via the JS API. Because we assert the + // challenge *value* from the Client Data matches the trusted one + // from the SignRequest and that value is included in the Challenge + // Parameter, this is safe unless/until SHA-256 is broken. + $this->clientData->getChallengeParameter() + ); + } + + /** + * @param array{ + * keyHandle: string, + * clientData: string, + * signatureData: string, + * } $response + */ + protected function parseResponse(array $response): self + { $this->validateKeyInArray('keyHandle', $response); $this->setKeyHandle(fromBase64Web($response['keyHandle'])); @@ -34,10 +66,15 @@ protected function parseResponse(array $response): self { throw new IDE(IDE::MALFORMED_DATA, 'signatureData'); } $decoded = unpack('cpresence/Ncounter/a*signature', $sig_raw); + assert($decoded !== false); $this->user_presence = $decoded['presence']; $this->counter = $decoded['counter']; $this->setSignature($decoded['signature']); return $this; } + public function getChallenge(): string + { + return $this->clientData->getChallenge(); + } } diff --git a/src/VersionTrait.php b/src/VersionTrait.php index a65f66a..6318686 100644 --- a/src/VersionTrait.php +++ b/src/VersionTrait.php @@ -5,9 +5,11 @@ trait VersionTrait { + /** @var 'U2F_V2' */ private $version = 'U2F_V2'; - public function getVersion(): string { + public function getVersion(): string + { return $this->version; } } diff --git a/src/functions.php b/src/functions.php index ddbbb3b..8fe26e7 100644 --- a/src/functions.php +++ b/src/functions.php @@ -4,54 +4,22 @@ /** * Takes a web-safe base64 encoded string and returns the decoded version - * @param the encoded string - * @return the raw binary string + * @param string $base64 the encoded string + * @return string the raw binary string */ -function fromBase64Web(string $base64): string { - return base64_decode(strtr($base64, '-_', '+/')); +function fromBase64Web(string $base64): string +{ + $decoded = base64_decode(strtr($base64, '-_', '+/')); + assert($decoded !== false); + return $decoded; } /** * Encodes any string to web-safe base64 - * @param the raw binary string - * @return the encoded string + * @param string $binary the raw binary string + * @return string the encoded string */ -function toBase64Web(string $binary): string { +function toBase64Web(string $binary): string +{ return rtrim(strtr(base64_encode($binary), '+/', '-_'), '='); } - -// Multibyte string wrappers: hijack calls to `strlen` and `substr` to force -// 8-bit encoding in the event that `mbstring.func_overload` parameter is -// non-zero and the mbstring default charset is not 8bit. - -/** - * Identical to `\strlen` except when `mbstring.func_overload` is enabled and - * set to a multi-byte character set, in which case it retains the - * non-overloaded behavior. - * - * @param string The string being measured - * @return int The length of the string, in bytes - */ -function strlen(string $string): int { - if (function_exists('mb_strlen')) { - return \mb_strlen($string, '8bit'); - } - return \strlen($string); -} - -/** - * Identical to `\substr` except when `mbstring.func_overload` is enabled and - * set to a multi-byte character set, in which case it retains the - * non-overloaded behavior. - * - * @param string The input string - * @param int The starting point, in bytes - * @param int The length, in bytes - * @return string The extracted part of the string - */ -function substr(string $string, int $start, int $length = null): string { - if (function_exists('mb_substr')) { - return \mb_substr($string, $start, $length, '8bit'); - } - return \substr($string, $start, $length); -} diff --git a/tests/AppIdTraitTest.php b/tests/AppIdTraitTest.php index 25ca4e4..8feaafa 100644 --- a/tests/AppIdTraitTest.php +++ b/tests/AppIdTraitTest.php @@ -8,35 +8,63 @@ * @covers :: * @covers :: */ -class AppIdTraitTest extends \PHPUnit_Framework_TestCase +class AppIdTraitTest extends \PHPUnit\Framework\TestCase { /** * @covers ::getAppId * @covers ::setAppId */ - public function testAccessors() { + public function testAccessors(): void + { $obj = new class { use AppIdTrait; }; $appId = 'https://u2f.example.com'; - $this->assertSame($obj, $obj->setAppId($appId), - 'setAppId should return $this'); - $this->assertSame($appId, $obj->getAppId(), - 'getAppId should return the set value'); + $this->assertSame( + $obj, + $obj->setAppId($appId), + 'setAppId should return $this' + ); + $this->assertSame( + $appId, + $obj->getAppId(), + 'getAppId should return the set value' + ); } /** * @covers ::getApplicationParameter */ - public function testGetApplicationParameter() { - $obj = new class { use AppIdTrait; }; + public function testGetApplicationParameter(): void + { + $obj = new class { + use AppIdTrait; + }; $appId = 'https://u2f.example.com'; $obj->setAppId($appId); - $this->assertSame(hash('sha256', $appId, true), + $this->assertSame( + hash('sha256', $appId, true), $obj->getApplicationParameter(), - 'getApplicationParamter should return the raw SHA256 hash of the application id'); + 'getApplicationParamter should return the raw SHA256 hash of the application id' + ); } + /** + * @covers ::getRpIdHash + */ + public function testGetRpIdHash(): void + { + $obj = new class { + use AppIdTrait; + }; + $appId = 'https://u2f.example.com'; + $obj->setAppId($appId); + $this->assertSame( + hash('sha256', $appId, true), + $obj->getRpIdHash(), + 'getRpIdHash should return the raw SHA256 hash of the application id' + ); + } } diff --git a/tests/AttestationCertificateTest.php b/tests/AttestationCertificateTest.php new file mode 100644 index 0000000..7c7b235 --- /dev/null +++ b/tests/AttestationCertificateTest.php @@ -0,0 +1,67 @@ + + * @covers :: + */ +class AttestationCertificateTest extends \PHPUnit\Framework\TestCase +{ + /** + * @covers ::__construct + */ + public function testConstruct(): void + { + // Note: a future, stricter implementation which actually parses and + // examines the ASN.1 format should fail on this. For now it's just + // a simple bag of bytes. + $raw = random_bytes(128); + $cert = new AttestationCertificate($raw); + $this->assertInstanceOf(AttestationCertificateInterface::class, $cert); + } + + /** + * @covers ::getBinary + */ + public function testGetBinary(): void + { + $raw = random_bytes(128); + $cert = new AttestationCertificate($raw); + $this->assertSame( + $raw, + $cert->getBinary(), + 'getBinary should return the untouched raw data' + ); + } + + /** + * @covers ::getPemFormatted + */ + public function testGetPemFormatted(): void + { + $raw = random_bytes(128); + $cert = new AttestationCertificate($raw); + $expected = "-----BEGIN CERTIFICATE-----\r\n"; + $expected .= chunk_split(base64_encode($raw), 64); + $expected .= "-----END CERTIFICATE-----"; + $this->assertSame( + $expected, + $cert->getPemFormatted(), + 'PEM-formatted certificate was incorrect' + ); + } + + /** + * @covers ::__debugInfo + */ + public function testDebugInfoEncodesBinary(): void + { + $cert = new AttestationCertificate(random_bytes(128)); + $debugInfo = $cert->__debugInfo(); + $this->assertArrayHasKey('binary', $debugInfo); + $this->assertRegExp('/^0x[0-9a-f]{256}$/', $debugInfo['binary']); + } +} diff --git a/tests/AttestationCertificateTraitTest.php b/tests/AttestationCertificateTraitTest.php deleted file mode 100644 index bd5466a..0000000 --- a/tests/AttestationCertificateTraitTest.php +++ /dev/null @@ -1,92 +0,0 @@ - - * @covers :: - */ -class AttestationCertificateTraitTest extends \PHPUnit_Framework_TestCase -{ - /** - * @covers ::getAttestationCertificateBinary - * @covers ::setAttestationCertificate - */ - public function testAccessors() { - $obj = new class { - use AttestationCertificateTrait; - }; - $cert = bin2hex(random_bytes(35)); - $this->assertSame($obj, $obj->setAttestationCertificate($cert), - 'setAttestationCertificate should return $this'); - $this->assertSame($cert, $obj->getAttestationCertificateBinary(), - 'getAttestationCertificate should return the challenge that was set'); - } - - /** - * @covers ::getAttestationCertificatePem - */ - public function testGetAttestationCertificatePem() { - $obj = new class { - use AttestationCertificateTrait; - }; - $raw = random_bytes(128); - $expected = "-----BEGIN CERTIFICATE-----\r\n"; - $expected .= chunk_split(base64_encode($raw), 64); - $expected .= "-----END CERTIFICATE-----"; - $obj->setAttestationCertificate($raw); - $this->assertSame($expected, $obj->getAttestationCertificatePem(), - 'PEM-formatted certificate was incorrect'); - } - - /** - * @covers ::verifyIssuerAgainstTrustedCAs - */ - public function testSuccessfulCAVerification() { - $class = $this->getObjectWithYubicoCert(); - $certs = [dirname(__DIR__).'/CAcerts/yubico.pem']; - $this->assertTrue($class->verifyIssuerAgainstTrustedCAs($certs)); - } - - /** - * @covers ::verifyIssuerAgainstTrustedCAs - */ - public function testFailedCAVerification() { - $class = $this->getObjectWithYubicoCert(); - $certs = [__DIR__.'/verisign_only_for_unit_tests.pem']; - $this->expectException(SecurityException::class); - $this->expectExceptionCode(SecurityException::NO_TRUSTED_CA); - $class->verifyIssuerAgainstTrustedCAs($certs); - } - - /** - * @covers ::verifyIssuerAgainstTrustedCAs - */ - public function testFailedCAVerificationFromNoCAs() { - $class = $this->getObjectWithYubicoCert(); - $certs = []; - $this->expectException(SecurityException::class); - $this->expectExceptionCode(SecurityException::NO_TRUSTED_CA); - $class->verifyIssuerAgainstTrustedCAs($certs); - } - - // -(Helper methods)------------------------------------------------------- - - /** - * Returns some concrete implementation of a class using - * AttestationCertificateTrait - * - * @return mixed Some class using AttestationCertificateTrait - */ - private function getObjectWithYubicoCert() { - $response = RegisterResponse::fromJson( - file_get_contents(__DIR__.'/register_response.json')); - // Sanity check that the response actually imlements this trait, rather - // than doing all sorts of magic - $check = AttestationCertificateTrait::class; - $this->assertContains($check, class_uses($response)); - return $response; - } -} diff --git a/tests/ChallengeTraitTest.php b/tests/ChallengeTraitTest.php index f026e84..a2a8a37 100644 --- a/tests/ChallengeTraitTest.php +++ b/tests/ChallengeTraitTest.php @@ -8,21 +8,28 @@ * @covers :: * @covers :: */ -class ChallengeTraitTest extends \PHPUnit_Framework_TestCase +class ChallengeTraitTest extends \PHPUnit\Framework\TestCase { /** * @covers ::getChallenge * @covers ::setChallenge */ - public function testAccessors() { + public function testAccessors(): void + { $obj = new class { use ChallengeTrait; }; $challenge = bin2hex(random_bytes(15)); - $this->assertSame($obj, $obj->setChallenge($challenge), - 'setChallenge should return $this'); - $this->assertSame($challenge, $obj->getChallenge(), - 'getChallenge should return the challenge that was set'); + $this->assertSame( + $obj, + $obj->setChallenge($challenge), + 'setChallenge should return $this' + ); + $this->assertSame( + $challenge, + $obj->getChallenge(), + 'getChallenge should return the challenge that was set' + ); } } diff --git a/tests/ClientDataTest.php b/tests/ClientDataTest.php index b9c5f1a..2ffc3b2 100644 --- a/tests/ClientDataTest.php +++ b/tests/ClientDataTest.php @@ -8,39 +8,76 @@ * @covers :: * @covers :: */ -class ClientDataTest extends \PHPUnit_Framework_TestCase +class ClientDataTest extends \PHPUnit\Framework\TestCase { - /** * @covers ::fromJson */ - public function testFromValidJson() { - $goodJson = '{"typ":"navigator.id.finishEnrollment","challenge":"PfsWR1Umy2V5Al1Bam2tG0yfPLeJElfwRzzAzkYPgzo","origin":"https://u2f.ericstern.com","cid_pubkey":""}'; + public function testFromValidJson(): void + { + $goodData = [ + 'typ' => 'navigator.id.finishEnrollment', + 'challenge' => 'PfsWR1Umy2V5Al1Bam2tG0yfPLeJElfwRzzAzkYPgzo', + 'origin' => 'https://u2f.ericstern.com', + 'cid_pubkey' => '', + ]; + $goodJson = json_encode($goodData); + assert($goodJson !== false); $clientData = ClientData::fromJson($goodJson); $this->assertInstanceOf(ClientData::class, $clientData); } /** * @covers ::getChallengeParameter - * @covers ::jsonSerialize */ - public function testGetChallengeParameter() { + public function testGetChallengeParameter(): void + { $expected_param = base64_decode('exDPjyyKbizXMAAUNLpv0QYJNyXClbUqewUWojPtp0g='); + assert($expected_param !== false); // Sanity check - $this->assertSame(32, + $this->assertSame( + 32, strlen($expected_param), - 'Test vector should have been 32 bytes'); + 'Test vector should have been 32 bytes' + ); + + $goodJson = '{"typ":"navigator.id.finishEnrollment","challenge":"PfsWR'. + '1Umy2V5Al1Bam2tG0yfPLeJElfwRzzAzkYPgzo","origin":"https://u2f.eri'. + 'cstern.com","cid_pubkey":""}'; - $goodJson = '{"typ":"navigator.id.finishEnrollment","challenge":"PfsWR1Umy2V5Al1Bam2tG0yfPLeJElfwRzzAzkYPgzo","origin":"https://u2f.ericstern.com","cid_pubkey":""}'; + assert($goodJson !== false); $clientData = ClientData::fromJson($goodJson); - $this->assertTrue(hash_equals($expected_param, $clientData->getChallengeParameter()), - 'Challenge parameter did not match expected value'); + $this->assertTrue( + hash_equals($expected_param, $clientData->getChallengeParameter()), + 'Challenge parameter did not match expected value' + ); + } + + /** + * @covers ::getApplicationParameter + */ + public function testGetApplicationParameter(): void + { + $goodData = [ + 'typ' => 'navigator.id.finishEnrollment', + 'challenge' => 'PfsWR1Umy2V5Al1Bam2tG0yfPLeJElfwRzzAzkYPgzo', + 'origin' => 'https://u2f.ericstern.com', + 'cid_pubkey' => '', + ]; + $goodJson = json_encode($goodData); + assert($goodJson !== false); + $clientData = ClientData::fromJson($goodJson); + $this->assertSame( + hash('sha256', 'https://u2f.ericstern.com', true), + $clientData->getApplicationParameter() + ); } /** * @covers ::fromJson */ - public function testBadJson() { + public function testBadJson(): void + { $json = 'this is not json'; $this->expectException(InvalidDataException::class); $this->expectExceptionCode(InvalidDataException::MALFORMED_DATA); @@ -51,7 +88,8 @@ public function testBadJson() { * @covers ::fromJson * @dataProvider missingData */ - public function testDataValidation($json) { + public function testDataValidation(string $json): void + { $this->expectException(InvalidDataException::class); $this->expectExceptionCode(InvalidDataException::MISSING_KEY); ClientData::fromJson($json); @@ -60,7 +98,8 @@ public function testDataValidation($json) { /** * @dataProvider types */ - public function testTypes(string $type, bool $allowed) { + public function testTypes(string $type, bool $allowed): void + { $all = [ 'typ' => $type, 'challenge' => 'SOMECHALLENGE', @@ -68,6 +107,7 @@ public function testTypes(string $type, bool $allowed) { 'cid_pubkey' => '', ]; $json = json_encode($all); + assert($json !== false); if (!$allowed) { $this->expectException(InvalidDataException::class); $this->expectExceptionCode(InvalidDataException::MALFORMED_DATA); @@ -79,26 +119,33 @@ public function testTypes(string $type, bool $allowed) { // -( DataProviders )------------------------------------------------------ - public function missingData(): array { + /** + * @return array{string}[] + */ + public function missingData(): array + { $all = [ 'typ' => 'navigator.id.finishEnrollment', 'challenge' => 'SOMECHALLENGE', 'origin' => 'https://u2f.example.com', 'cid_pubkey' => '', ]; - $without = function(string $i) use ($all): array { + $without = function (string $i) use ($all): array { unset($all[$i]); - return [json_encode($all)]; + return [json_encode($all, JSON_THROW_ON_ERROR)]; }; return [ $without('typ'), $without('challenge'), $without('origin'), - $without('cid_pubkey'), ]; } - public function types(): array { + /** + * @return array{string, bool}[] + */ + public function types(): array + { return [ ['navigator.id.getAssertion', true], ['navigator.id.finishEnrollment', true], diff --git a/tests/ClientErrorExceptionTest.php b/tests/ClientErrorExceptionTest.php index eecbe1f..a32ee94 100644 --- a/tests/ClientErrorExceptionTest.php +++ b/tests/ClientErrorExceptionTest.php @@ -8,23 +8,30 @@ * @covers :: * @covers :: */ -class ClientErrorExceptionTest extends \PHPUnit_Framework_TestCase +class ClientErrorExceptionTest extends \PHPUnit\Framework\TestCase { /** * @covers ::__construct * @dataProvider clientErrors */ - public function testClientError(int $code) { + public function testClientError(int $code): void + { $ex = new ClientErrorException($code); - $this->assertInstanceOf(ClientErrorException::class, + $this->assertInstanceOf( + ClientErrorException::class, $ex, - '__construct failed'); - $this->assertNotEmpty($ex->getMessage(), - 'A predefined message should have been used'); + '__construct failed' + ); + $this->assertNotEmpty( + $ex->getMessage(), + 'A predefined message should have been used' + ); } // -( DataProviders )------------------------------------------------------ - public function clientErrors() { + /** @return array{int}[] */ + public function clientErrors() + { return [ [ClientError::OTHER_ERROR], [ClientError::BAD_REQUEST], @@ -33,6 +40,4 @@ public function clientErrors() { [ClientError::TIMEOUT], ]; } - - } diff --git a/tests/ECPublicKeyTest.php b/tests/ECPublicKeyTest.php new file mode 100644 index 0000000..99b569d --- /dev/null +++ b/tests/ECPublicKeyTest.php @@ -0,0 +1,104 @@ + + * @covers :: + */ +class ECPublicKeyTest extends \PHPUnit\Framework\TestCase +{ + /** + * @covers ::__construct + */ + public function testConstruct(): void + { + $key = "\x04".\random_bytes(64); + $obj = new ECPublicKey($key); + $this->assertInstanceOf(PublicKeyInterface::class, $obj); + } + + /** + * @covers ::__construct + */ + public function testConstructThrowsWithBadFirstByte(): void + { + $key = "\x01".\random_bytes(64); + $this->expectException(InvalidDataException::class); + $this->expectExceptionCode(InvalidDataException::MALFORMED_DATA); + new ECPublicKey($key); + } + + + /** + * @covers ::__construct + */ + public function testConstructThrowsWhenTooShort(): void + { + $key = "\x04".random_bytes(63); + $this->expectException(InvalidDataException::class); + $this->expectExceptionCode(InvalidDataException::PUBLIC_KEY_LENGTH); + new ECPublicKey($key); + } + + /** + * @covers ::__construct + */ + public function testConstructThrowsWhenTooLong(): void + { + $key = "\x04".random_bytes(65); + $this->expectException(InvalidDataException::class); + $this->expectExceptionCode(InvalidDataException::PUBLIC_KEY_LENGTH); + new ECPublicKey($key); + } + + /** + * @covers ::getBinary + */ + public function testGetBinary(): void + { + $key = "\x04".\random_bytes(64); + $obj = new ECPublicKey($key); + $this->assertSame( + $key, + $obj->getBinary(), + 'getBinary should return the set value' + ); + } + + /** + * @covers ::getPemFormatted + */ + public function testGetPublicKeyPem(): void + { + $key = hex2bin( + '04b4960ae0fa301033fbedc85c33ac30408dffd6098bc8580d8b66159959d89b9'. + '31daf1d43a1949b07b7d47eea25efcac478bb5cd6ead0a3c3f7b7cb2a7bc1e3be' + ); + assert($key !== false); + $obj = new ECPublicKey($key); + $pem = + "-----BEGIN PUBLIC KEY-----\r\n". + "MFkwEwYHKoZIzj0CAQYIKoZIzj0DAQcDQgAEtJYK4PowEDP77chcM6wwQI3/1gmL\r\n". + "yFgNi2YVmVnYm5Mdrx1DoZSbB7fUfuol78rEeLtc1urQo8P3t8sqe8Hjvg==\r\n". + "-----END PUBLIC KEY-----"; + + $this->assertSame($pem, $obj->getPemFormatted()); + } + + /** + * @covers ::__debugInfo + */ + public function testDebugInfoEncodesBinary(): void + { + $x = random_bytes(32); + $y = random_bytes(32); + $pk = new ECPublicKey("\x04$x$y"); + $debugInfo = $pk->__debugInfo(); + $this->assertArrayHasKey('x', $debugInfo); + $this->assertSame('0x'.bin2hex($x), $debugInfo['x'], 'x-coordinate wrong'); + $this->assertSame('0x'.bin2hex($y), $debugInfo['y'], 'y-coordinate wrong'); + } +} diff --git a/tests/ECPublicKeyTraitTest.php b/tests/ECPublicKeyTraitTest.php deleted file mode 100644 index 9daacc0..0000000 --- a/tests/ECPublicKeyTraitTest.php +++ /dev/null @@ -1,93 +0,0 @@ - - * @covers :: - */ -class ECPublicKeyTraitTest extends \PHPUnit_Framework_TestCase -{ - - /** - * @covers ::setPublicKey - * @covers ::getPublicKey - */ - public function testAccessors() { - $obj = new class { - use ECPublicKeyTrait; - }; - $key = "\x04".\random_bytes(64); - $this->assertSame($obj, $obj->setPublicKey($key), - 'setPublicKey should return $this'); - $this->assertSame($key, $obj->getPublicKey(), - 'getPublicKey should return the set value'); - } - - /** - * @covers ::getPublicKeyPem - */ - public function testGetPublicKeyPem() { - $obj = new class { - use ECPublicKeyTrait; - }; - - $key = hex2bin( - '04b4960ae0fa301033fbedc85c33ac30408dffd6098bc8580d8b66159959d89b9'. - '31daf1d43a1949b07b7d47eea25efcac478bb5cd6ead0a3c3f7b7cb2a7bc1e3be' - ); - $pem = - "-----BEGIN PUBLIC KEY-----\r\n". - "MFkwEwYHKoZIzj0CAQYIKoZIzj0DAQcDQgAEtJYK4PowEDP77chcM6wwQI3/1gmL\r\n". - "yFgNi2YVmVnYm5Mdrx1DoZSbB7fUfuol78rEeLtc1urQo8P3t8sqe8Hjvg==\r\n". - "-----END PUBLIC KEY-----"; - - $obj->setPublicKey($key); - $this->assertSame($pem, $obj->getPublicKeyPem()); - } - - /** - * @covers ::setPublicKey - */ - public function testSetPublicKeyThrowsWithBadFirstByte() { - $obj = new class { - use ECPublicKeyTrait; - }; - $key = "\x01".\random_bytes(64); - $this->expectException(InvalidDataException::class); - $this->expectExceptionCode(InvalidDataException::MALFORMED_DATA); - $obj->setPublicKey($key); - } - - - /** - * @covers ::setPublicKey - */ - public function testSetPublicKeyThrowsWhenTooShort() { - $obj = new class { - use ECPublicKeyTrait; - }; - $key = "\x04".random_bytes(63); - $this->expectException(InvalidDataException::class); - $this->expectExceptionCode(InvalidDataException::PUBLIC_KEY_LENGTH); - $obj->setPublicKey($key); - } - - /** - * @covers ::setPublicKey - */ - public function testSetPublicKeyThrowsWhenTooLong() { - $obj = new class { - use ECPublicKeyTrait; - }; - $key = "\x04".random_bytes(65); - $this->expectException(InvalidDataException::class); - $this->expectExceptionCode(InvalidDataException::PUBLIC_KEY_LENGTH); - $obj->setPublicKey($key); - } - - - -} diff --git a/tests/functionsTest.php b/tests/FunctionsTest.php similarity index 58% rename from tests/functionsTest.php rename to tests/FunctionsTest.php index 85b9e2a..da61992 100644 --- a/tests/functionsTest.php +++ b/tests/FunctionsTest.php @@ -3,29 +3,37 @@ namespace Firehed\U2F; -class functionsTest extends \PHPUnit_Framework_TestCase +class FunctionsTest extends \PHPUnit\Framework\TestCase { - - /** * @covers Firehed\U2F\fromBase64Web * @dataProvider vectors */ - public function testFromBase64Web($plain, $encoded) { - $this->assertSame($plain, fromBase64Web($encoded), - 'Decoded vector did not match known plaintext version'); + public function testFromBase64Web(string $plain, string $encoded): void + { + $this->assertSame( + $plain, + fromBase64Web($encoded), + 'Decoded vector did not match known plaintext version' + ); } /** * @covers Firehed\U2F\toBase64Web * @dataProvider vectors */ - public function testToBase64Web($plain, $encoded) { - $this->assertSame($encoded, toBase64Web($plain), - 'Encoded vector did not match known version'); + public function testToBase64Web(string $plain, string $encoded): void + { + $this->assertSame( + $encoded, + toBase64Web($plain), + 'Encoded vector did not match known version' + ); } - public function vectors(): array { + /** @return array{string, string}[] */ + public function vectors(): array + { return [ // Plain Encoded // Adapted test vctors from RFC4648 Sec. 5 (padding trimmed) diff --git a/tests/InvalidDataExceptionTest.php b/tests/InvalidDataExceptionTest.php index 933b9d9..012d8fb 100644 --- a/tests/InvalidDataExceptionTest.php +++ b/tests/InvalidDataExceptionTest.php @@ -8,30 +8,39 @@ * @covers :: * @covers :: */ -class InvalidDataExceptionTest extends \PHPUnit_Framework_TestCase +class InvalidDataExceptionTest extends \PHPUnit\Framework\TestCase { /** * @covers ::__construct - * @dataProvider invalidDataExceptionCodes + * @dataProvider invalidDataExceptionCodes */ - public function testInvalidDataException(int $code) { + public function testInvalidDataException(int $code): void + { $ex = new InvalidDataException($code, 'Prefill'); - $this->assertInstanceOf(InvalidDataException::class, + $this->assertInstanceOf( + InvalidDataException::class, $ex, - '__construct failed'); - $this->assertNotEmpty($ex->getMessage(), - 'A predefined message should have been used'); - $this->assertRegExp('/Prefill/', $ex->getMessage(), - 'The sprintf args were not added to the exception message'); + '__construct failed' + ); + $this->assertNotEmpty( + $ex->getMessage(), + 'A predefined message should have been used' + ); + $this->assertRegExp( + '/Prefill/', + $ex->getMessage(), + 'The sprintf args were not added to the exception message' + ); } // -( DataProviders )------------------------------------------------------ - public function invalidDataExceptionCodes() { + /** @return array{int}[] */ + public function invalidDataExceptionCodes() + { return [ [InvalidDataException::MISSING_KEY], [InvalidDataException::MALFORMED_DATA], [InvalidDataException::PUBLIC_KEY_LENGTH], ]; } - } diff --git a/tests/KeyHandleTraitTest.php b/tests/KeyHandleTraitTest.php index 6723e44..26c1aa9 100644 --- a/tests/KeyHandleTraitTest.php +++ b/tests/KeyHandleTraitTest.php @@ -8,35 +8,45 @@ * @covers :: * @covers :: */ -class KeyHandleTraitTest extends \PHPUnit_Framework_TestCase +class KeyHandleTraitTest extends \PHPUnit\Framework\TestCase { /** * @covers ::getKeyHandleBinary * @covers ::setKeyHandle */ - public function testAccessors() { + public function testAccessors(): void + { $obj = new class { use KeyHandleTrait; }; $kh = random_bytes(14)."\x00 "; - $this->assertSame($obj, $obj->setKeyHandle($kh), - 'setKeyHandle should return $this'); - $this->assertSame($kh, $obj->getKeyHandleBinary(), - 'getKeyHandleBinary should return the set value'); + $this->assertSame( + $obj, + $obj->setKeyHandle($kh), + 'setKeyHandle should return $this' + ); + $this->assertSame( + $kh, + $obj->getKeyHandleBinary(), + 'getKeyHandleBinary should return the set value' + ); } /** * @covers ::getKeyHandleWeb */ - public function testGetKeyHandleWeb() { + public function testGetKeyHandleWeb(): void + { $obj = new class { use KeyHandleTrait; }; $kh = random_bytes(14)."\x00 "; $webKh = toBase64Web($kh); $obj->setKeyHandle($kh); - $this->assertSame($webKh, $obj->getKeyHandleWeb(), - 'getKeyHandleWeb was encoded wrong'); + $this->assertSame( + $webKh, + $obj->getKeyHandleWeb(), + 'getKeyHandleWeb was encoded wrong' + ); } - } diff --git a/tests/RegisterRequestTest.php b/tests/RegisterRequestTest.php index 9eb3a13..54eb2d8 100644 --- a/tests/RegisterRequestTest.php +++ b/tests/RegisterRequestTest.php @@ -8,13 +8,14 @@ * @covers :: * @covers :: */ -class RegisterRequestTest extends \PHPUnit_Framework_TestCase +class RegisterRequestTest extends \PHPUnit\Framework\TestCase { /** * @covers ::jsonSerialize */ - public function testJsonSerialize() { + public function testJsonSerialize(): void + { $appId = 'https://u2f.example.com'; $challenge = 'some-random-string'; @@ -23,21 +24,37 @@ public function testJsonSerialize() { ->setAppId($appId) ->setChallenge($challenge); $json = json_encode($request); + assert($json !== false); $decoded = json_decode($json, true); - $this->assertSame($appId, $request->getAppId(), - 'getAppId returned the wrong value'); - $this->assertSame($appId, $decoded['appId'], - 'json appId property did not match'); - $this->assertSame($challenge, $request->getChallenge(), - 'getChallenge returned the wrong value'); - $this->assertSame($challenge, $decoded['challenge'], - 'json challenge property did not match'); - $this->assertSame('U2F_V2', $request->getVersion(), - 'getVersion returned the wrong value'); - $this->assertSame('U2F_V2', $decoded['version'], - 'json version was incorrect'); - - + $this->assertSame( + $appId, + $request->getAppId(), + 'getAppId returned the wrong value' + ); + $this->assertSame( + $appId, + $decoded['appId'], + 'json appId property did not match' + ); + $this->assertSame( + $challenge, + $request->getChallenge(), + 'getChallenge returned the wrong value' + ); + $this->assertSame( + $challenge, + $decoded['challenge'], + 'json challenge property did not match' + ); + $this->assertSame( + 'U2F_V2', + $request->getVersion(), + 'getVersion returned the wrong value' + ); + $this->assertSame( + 'U2F_V2', + $decoded['version'], + 'json version was incorrect' + ); } - } diff --git a/tests/RegisterResponseTest.php b/tests/RegisterResponseTest.php index 4e09a50..c8d6feb 100644 --- a/tests/RegisterResponseTest.php +++ b/tests/RegisterResponseTest.php @@ -8,17 +8,46 @@ * @covers :: * @covers :: */ -class RegisterResponseTest extends \PHPUnit_Framework_TestCase +class RegisterResponseTest extends \PHPUnit\Framework\TestCase { - private $validClientData = 'eyJ0eXAiOiJuYXZpZ2F0b3IuaWQuZmluaXNoRW5yb2xsbWVudCIsImNoYWxsZW5nZSI6IkJyQWN4dGIxOWFYNTRoN0Y2T0NKWVptQ3prZHlHV0Nib3NEcHpNMUh2MkUiLCJvcmlnaW4iOiJodHRwczovL3UyZi5lcmljc3Rlcm4uY29tIiwiY2lkX3B1YmtleSI6IiJ9'; - private $validRegistrationData = "BQS55FfGvxbgmcNO1cpNhdr4r-CMSbMtuhiMMJbXqd_3FD8Aah2X_n4ZiyBlgBqbbe4RdyksR7ZXoqPYT47-tmeWQJhf7xs1T8ObBRpkFi_VWG5oFJe499mQYxcj9BR0G8B5fjkYbUuPCwNRiscOP8P18ep6V1OOulT3tq6kBC-94xQwggItMIIBF6ADAgECAgQFtgV5MAsGCSqGSIb3DQEBCzAuMSwwKgYDVQQDEyNZdWJpY28gVTJGIFJvb3QgQ0EgU2VyaWFsIDQ1NzIwMDYzMTAgFw0xNDA4MDEwMDAwMDBaGA8yMDUwMDkwNDAwMDAwMFowKDEmMCQGA1UEAwwdWXViaWNvIFUyRiBFRSBTZXJpYWwgOTU4MTUwMzMwWTATBgcqhkjOPQIBBggqhkjOPQMBBwNCAAT9uN6zoe1w62NsBm62AGmWpflw_LXbiPw7MF1B5ZZvDBtUuFL-8KCQftF_O__CnU0yG5z4qEos6qA4yr011ZjeoyYwJDAiBgkrBgEEAYLECgIEFTEuMy42LjEuNC4xLjQxNDgyLjEuMTALBgkqhkiG9w0BAQsDggEBAH7T-2zMJSAT-C8hjCo32mAx0g5_MIHa_K6xKPx_myM5FL-2TWE18XziIfp2T0U-8Sc6jOlllWRCuy8eR0g_c33LyYtYU3f-9QsnDgKJ-IQ28a3PSbJiHuXjAt9VW5q3QnLgafkYFJs97E8SIosQwPiN42r1inS7RCuFrgBTZL2mcCBY_B8th5tTARHqYOhsY_F_pZRMyD8KommEiz7jiKbAnmsFlT_LuPR-g6J-AHKmPDKtZIZOkm1xEvoZl_eDllb7syvo94idDwFFUZonr92ORrBMpCkNhUC2NLiGFh51iMhimdzdZDXRZ4o6bwp0gpxN0_cMNSTR3fFteK3SG2QwRAIgFTLJPY9_a0ZPujRfLufS-9ANCWemIWPHqs3icavMJIgCIFH5MSGDFkuY_NWhKa4mbLdbP6r7wMwspwHPG5_Xf48V"; + /** @var string */ + private $validClientData = + 'eyJ0eXAiOiJuYXZpZ2F0b3IuaWQuZmluaXNoRW5yb2xsbWVudCIsImNoYWxsZW5nZSI6I'. + 'kJyQWN4dGIxOWFYNTRoN0Y2T0NKWVptQ3prZHlHV0Nib3NEcHpNMUh2MkUiLCJvcmlnaW'. + '4iOiJodHRwczovL3UyZi5lcmljc3Rlcm4uY29tIiwiY2lkX3B1YmtleSI6IiJ9'; + + /** @var string */ + private $validRegistrationData = + 'BQS55FfGvxbgmcNO1cpNhdr4r-CMSbMtuhiMMJbXqd_3FD8Aah2X_n4ZiyBlgBqbbe4Rd'. + 'yksR7ZXoqPYT47-tmeWQJhf7xs1T8ObBRpkFi_VWG5oFJe499mQYxcj9BR0G8B5fjkYbU'. + 'uPCwNRiscOP8P18ep6V1OOulT3tq6kBC-94xQwggItMIIBF6ADAgECAgQFtgV5MAsGCSq'. + 'GSIb3DQEBCzAuMSwwKgYDVQQDEyNZdWJpY28gVTJGIFJvb3QgQ0EgU2VyaWFsIDQ1NzIw'. + 'MDYzMTAgFw0xNDA4MDEwMDAwMDBaGA8yMDUwMDkwNDAwMDAwMFowKDEmMCQGA1UEAwwdW'. + 'XViaWNvIFUyRiBFRSBTZXJpYWwgOTU4MTUwMzMwWTATBgcqhkjOPQIBBggqhkjOPQMBBw'. + 'NCAAT9uN6zoe1w62NsBm62AGmWpflw_LXbiPw7MF1B5ZZvDBtUuFL-8KCQftF_O__CnU0'. + 'yG5z4qEos6qA4yr011ZjeoyYwJDAiBgkrBgEEAYLECgIEFTEuMy42LjEuNC4xLjQxNDgy'. + 'LjEuMTALBgkqhkiG9w0BAQsDggEBAH7T-2zMJSAT-C8hjCo32mAx0g5_MIHa_K6xKPx_m'. + 'yM5FL-2TWE18XziIfp2T0U-8Sc6jOlllWRCuy8eR0g_c33LyYtYU3f-9QsnDgKJ-IQ28a'. + '3PSbJiHuXjAt9VW5q3QnLgafkYFJs97E8SIosQwPiN42r1inS7RCuFrgBTZL2mcCBY_B8'. + 'th5tTARHqYOhsY_F_pZRMyD8KommEiz7jiKbAnmsFlT_LuPR-g6J-AHKmPDKtZIZOkm1x'. + 'EvoZl_eDllb7syvo94idDwFFUZonr92ORrBMpCkNhUC2NLiGFh51iMhimdzdZDXRZ4o6b'. + 'wp0gpxN0_cMNSTR3fFteK3SG2QwRAIgFTLJPY9_a0ZPujRfLufS-9ANCWemIWPHqs3ica'. + 'vMJIgCIFH5MSGDFkuY_NWhKa4mbLdbP6r7wMwspwHPG5_Xf48V'; /** * @covers ::fromJson */ - public function testFromJson() { - $json = sprintf('{"registrationData":"%s","version":"U2F_V2","challenge":"BrAcxtb19aX54h7F6OCJYZmCzkdyGWCbosDpzM1Hv2E","appId":"https://u2f.ericstern.com","clientData":"%s"}', $this->validRegistrationData, $this->validClientData);; + public function testFromJson(): void + { + $json = json_encode([ + 'registrationData' => $this->validRegistrationData, + 'version' => 'U2F_V2', + 'challenge' => 'BrAcxtb19aX54h7F6OCJYZmCzkdyGWCbosDpzM1Hv2E', + 'appId' => 'https://u2f.ericstern.com', + 'clientData' => $this->validClientData, + ]); + assert($json !== false); $response = RegisterResponse::fromJson($json); $this->assertInstanceOf(RegisterResponse::class, $response); } @@ -26,21 +55,24 @@ public function testFromJson() { /** * @dataProvider clientErrors */ - public function testErrorResponse(int $code) { + public function testErrorResponse(int $code): void + { $json = sprintf('{"errorCode":%d}', $code); $this->expectException(ClientErrorException::class); $this->expectExceptionCode($code); RegisterResponse::fromJson($json); } - public function testFromJsonBadJson() { + public function testFromJsonBadJson(): void + { $json = 'this is not json'; $this->expectException(InvalidDataException::class); // FIXME: code RegisterResponse::fromJson($json); } - public function testFromJsonMissingClientData() { + public function testFromJsonMissingClientData(): void + { $json = sprintf('{"registrationData":"%s"}', $this->validRegistrationData); $this->expectException(InvalidDataException::class); $this->expectExceptionCode(InvalidDataException::MISSING_KEY); @@ -48,7 +80,8 @@ public function testFromJsonMissingClientData() { RegisterResponse::fromJson($json); } - public function testFromJsonMissingRegistrationData() { + public function testFromJsonMissingRegistrationData(): void + { $json = sprintf('{"clientData":"%s"}', $this->validClientData); $this->expectException(InvalidDataException::class); $this->expectExceptionCode(InvalidDataException::MISSING_KEY); @@ -59,7 +92,8 @@ public function testFromJsonMissingRegistrationData() { /** * @dataProvider invalidRegistrationData */ - public function testBadRegistrationData(string $registrationData) { + public function testBadRegistrationData(string $registrationData): void + { $json = $this->buildJson($this->validClientData, $registrationData); $this->expectException(InvalidDataException::class); $this->expectExceptionCode(InvalidDataException::MALFORMED_DATA); @@ -67,12 +101,13 @@ public function testBadRegistrationData(string $registrationData) { } /** - * @covers ::getAttestationCertificateBinary + * @covers ::getAttestationCertificate * @covers ::getKeyHandleBinary * @covers ::getPublicKey * @covers ::getSignature */ - public function testDataAccuracyAfterSuccessfulParsing() { + public function testDataAccuracyAfterSuccessfulParsing(): void + { $pubkey = "\x04".random_bytes(64); $handle = random_bytes(32); $st = "\x05".$pubkey."\x20".$handle; @@ -83,19 +118,96 @@ public function testDataAccuracyAfterSuccessfulParsing() { $json = $this->buildJson($this->validClientData, $reg); $response = RegisterResponse::fromJson($json); - $this->assertSame($pubkey, $response->getPublicKey(), - 'Public key was not parsed correctly'); - $this->assertSame($handle, $response->getKeyHandleBinary(), - 'Key Handle was not parsed correctly'); - $this->assertSame($cert, $response->getAttestationCertificateBinary(), - 'Cert was not parsed correctly'); - $this->assertSame($sig, $response->getSignature(), - 'Signature was not parsed correctly'); + $this->assertSame( + $pubkey, + $response->getPublicKey()->getBinary(), + 'Public key was not parsed correctly' + ); + $this->assertSame( + $handle, + $response->getKeyHandleBinary(), + 'Key Handle was not parsed correctly' + ); + $this->assertSame( + $cert, + $response->getAttestationCertificate()->getBinary(), + 'Cert was not parsed correctly' + ); + $this->assertSame( + $sig, + $response->getSignature(), + 'Signature was not parsed correctly' + ); + } + + /** + * @covers ::getSignedData + */ + public function testGetSignedData(): void + { + $json = file_get_contents(__DIR__ . '/register_response.json'); + assert($json !== false); + $response = RegisterResponse::fromJson($json); + + $expectedSignedData = sprintf( + '%s%s%s%s%s', + "\x00", + hash('sha256', 'https://u2f.ericstern.com', true), + hash( + 'sha256', + '{'. + '"typ":"navigator.id.finishEnrollment",'. + '"challenge":"PfsWR1Umy2V5Al1Bam2tG0yfPLeJElfwRzzAzkYPgzo",'. + '"origin":"https://u2f.ericstern.com",'. + '"cid_pubkey":""'. + '}', + true + ), + $response->getKeyHandleBinary(), + $response->getPublicKey()->getBinary() + ); + + $this->assertSame( + $expectedSignedData, + $response->getSignedData(), + 'Wrong signed data' + ); + } + + /** + * @covers ::getChallenge + */ + public function testGetChallenge(): void + { + $json = file_get_contents(__DIR__ . '/register_response.json'); + assert($json !== false); + $response = RegisterResponse::fromJson($json); + + $this->assertSame( + 'PfsWR1Umy2V5Al1Bam2tG0yfPLeJElfwRzzAzkYPgzo', + $response->getChallenge() + ); + } + /** + * @covers ::getRpIdHash + */ + public function testGetRpIdHash(): void + { + $json = file_get_contents(__DIR__ . '/register_response.json'); + assert($json !== false); + $response = RegisterResponse::fromJson($json); + + $this->assertSame( + hash('sha256', 'https://u2f.ericstern.com', true), + $response->getRpIdHash() + ); } // -( DataProviders )------------------------------------------------------ - public function clientErrors() { + /** @return array{int}[] */ + public function clientErrors() + { return [ [ClientError::OTHER_ERROR], [ClientError::BAD_REQUEST], @@ -105,9 +217,11 @@ public function clientErrors() { ]; } - public function invalidRegistrationData(): array { - $bad_reserved_byte = "\x01".str_repeat('a',200); - $bad_pubkey_start = "\x05\x99".str_repeat('a',200); + /** @return array{string}[] */ + public function invalidRegistrationData(): array + { + $bad_reserved_byte = "\x01".str_repeat('a', 200); + $bad_pubkey_start = "\x05\x99".str_repeat('a', 200); $pubkey_too_short = "\x05\x04".random_bytes(5); $handle_too_short = "\x05\x04".random_bytes(64)."\x20".random_bytes(16); @@ -116,8 +230,8 @@ public function invalidRegistrationData(): array { $valid_start = "\x05\x04".random_bytes(64)."\x20".random_bytes(32); $bad_cert_start = "\x40".str_repeat('a', 100); // Must start with bxxx10000 $crazy_long_cert = "\x30\x85".str_repeat('a', 100); - $too_short_cert = "\x30\x82\x01\x00".str_repeat('a',50); // x0100 bytes long - return array_map(function(string $s) { + $too_short_cert = "\x30\x82\x01\x00".str_repeat('a', 50); // x0100 bytes long + return array_map(function (string $s) { return [toBase64Web($s)]; }, [ $bad_reserved_byte, @@ -132,8 +246,10 @@ public function invalidRegistrationData(): array { // -( Helpers )------------------------------------------------------------ - protected function buildJson($clientData, $registrationData): string { - return sprintf('{"clientData":"%s","registrationData":"%s"}', + protected function buildJson(string $clientData, string $registrationData): string + { + return sprintf( + '{"clientData":"%s","registrationData":"%s"}', $clientData, $registrationData ); diff --git a/tests/RegistrationTest.php b/tests/RegistrationTest.php index 7a73dea..63eaf52 100644 --- a/tests/RegistrationTest.php +++ b/tests/RegistrationTest.php @@ -8,27 +8,76 @@ * @covers :: * @covers :: */ -class RegistrationTest extends \PHPUnit_Framework_TestCase +class RegistrationTest extends \PHPUnit\Framework\TestCase { /** * @covers ::setCounter * @covers ::getCounter */ - public function testCounter() { + public function testCounter(): void + { $obj = new Registration(); - $this->assertSame($obj, $obj->setCounter(1833), - 'setCounter should return $this'); - $this->assertSame(1833, $obj->getCounter(), - 'getCounter should return the set value'); + $this->assertSame( + $obj, + $obj->setCounter(1833), + 'setCounter should return $this' + ); + $this->assertSame( + 1833, + $obj->getCounter(), + 'getCounter should return the set value' + ); } /** * @covers ::setCounter */ - public function testSetCounterRejectsNegativeNumbers() { + public function testSetCounterRejectsNegativeNumbers(): void + { $obj = new Registration(); $this->expectException(\OutOfBoundsException::class); $obj->setCounter(-1); } + + /** + * @covers ::getPublicKey + * @covers ::setPublicKey + */ + public function testPublicKey(): void + { + $pk = $this->createMock(PublicKeyInterface::class); + $reg = new Registration(); + $reg->setPublicKey($pk); + $this->assertSame($pk, $reg->getPublicKey()); + } + + /** + * @covers ::getAttestationCertificate + * @covers ::setAttestationCertificate + */ + public function testAttestationCertificate(): void + { + $pk = $this->createMock(AttestationCertificateInterface::class); + $reg = new Registration(); + $reg->setAttestationCertificate($pk); + $this->assertSame($pk, $reg->getAttestationCertificate()); + } + + /** + * @covers ::__debugInfo + */ + public function testDebugInfoEncodesBinary(): void + { + $reg = new Registration(); + $reg->setAttestationCertificate($this->createMock(AttestationCertificateInterface::class)); + $reg->setPublicKey($this->createMock(PublicKeyInterface::class)); + $kh = random_bytes(20); + $reg->setKeyHandle($kh); + $reg->setCounter(50); + + $debugInfo = $reg->__debugInfo(); + $this->assertNotSame($kh, $debugInfo['keyHandle']); + $this->assertRegExp('/^0x[0-9a-f]{40}$/', $debugInfo['keyHandle']); + } } diff --git a/tests/ResponseTraitTest.php b/tests/ResponseTraitTest.php index 7a81aad..903b32d 100644 --- a/tests/ResponseTraitTest.php +++ b/tests/ResponseTraitTest.php @@ -8,14 +8,17 @@ * @covers :: * @covers :: */ -class ResponseTraitTest extends \PHPUnit_Framework_TestCase +class ResponseTraitTest extends \PHPUnit\Framework\TestCase { + /** @var object */ private $trait; - public function setUp() { + public function setUp(): void + { $this->trait = new class { use ResponseTrait; - protected function parseResponse(array $response): self { + protected function parseResponse(array $response): self + { $this->setSignature($response['signature']); return $this; } @@ -25,12 +28,15 @@ protected function parseResponse(array $response): self { /** * @covers ::fromJson * @covers ::getSignature - * @covers ::getClientData */ - public function testValidJson() { + public function testValidJson(): void + { $signature = __METHOD__; $json = json_encode([ - 'clientData' => 'eyJ0eXAiOiJuYXZpZ2F0b3IuaWQuZ2V0QXNzZXJ0aW9uIiwiY2hhbGxlbmdlIjoid3QyemU4SXNrY1RPM25Jc08yRDJoRmpFNXRWRDA0MU5wblllc0xwSndlZyIsIm9yaWdpbiI6Imh0dHBzOi8vdTJmLmVyaWNzdGVybi5jb20iLCJjaWRfcHVia2V5IjoiIn0', + 'clientData' => 'eyJ0eXAiOiJuYXZpZ2F0b3IuaWQuZ2V0QXNzZXJ0aW9uIiwi'. + 'Y2hhbGxlbmdlIjoid3QyemU4SXNrY1RPM25Jc08yRDJoRmpFNXRWRDA0MU5w'. + 'blllc0xwSndlZyIsIm9yaWdpbiI6Imh0dHBzOi8vdTJmLmVyaWNzdGVybi5j'. + 'b20iLCJjaWRfcHVia2V5IjoiIn0', 'signature' => $signature, ]); @@ -38,20 +44,24 @@ public function testValidJson() { // This is a little goofy because it's an anonymous class, but seems // preferable to declaring a one-off class in the test to implement the // trait instead. - $this->assertInstanceOf(get_class($this->trait), $response, - 'Parsed response was the wrong type'); + $this->assertInstanceOf( + get_class($this->trait), + $response, + 'Parsed response was the wrong type' + ); - $this->assertInstanceOf(ClientData::class, $response->getClientData(), - 'ClientData was not parsed correctly'); - - $this->assertSame(__METHOD__, $response->getSignature(), - 'Signature was not parsed correctly'); + $this->assertSame( + __METHOD__, + $response->getSignature(), + 'Signature was not parsed correctly' + ); } /** * @covers ::fromJson */ - public function testFromJsonWithNonJson() { + public function testFromJsonWithNonJson(): void + { $this->expectException(InvalidDataException::class); $this->expectExceptionCode(InvalidDataException::MALFORMED_DATA); $this->trait::fromJson('this is not json'); @@ -61,7 +71,8 @@ public function testFromJsonWithNonJson() { * @covers ::fromJson * @dataProvider clientErrors */ - public function testErrorResponse(int $code) { + public function testErrorResponse(int $code): void + { $json = sprintf('{"errorCode":%d}', $code); $this->expectException(ClientErrorException::class); $this->expectExceptionCode($code); @@ -72,7 +83,8 @@ public function testErrorResponse(int $code) { * @covers ::fromJson * @dataProvider badClientData */ - public function testClientDataValidation(string $json, int $code) { + public function testClientDataValidation(string $json, int $code): void + { $this->expectException(InvalidDataException::class); $this->expectExceptionCode($code); $this->trait::fromJson($json); @@ -80,7 +92,9 @@ public function testClientDataValidation(string $json, int $code) { // -( DataProviders )------------------------------------------------------ - public function clientErrors() { + /** @return array{int}[] */ + public function clientErrors(): array + { return [ [ClientError::OTHER_ERROR], [ClientError::BAD_REQUEST], @@ -90,11 +104,12 @@ public function clientErrors() { ]; } - public function badClientData() { + /** @return array{string, int}[] */ + public function badClientData(): array + { return [ ['{}', InvalidDataException::MISSING_KEY], ['{"clientData":25}', InvalidDataException::MALFORMED_DATA], ]; } - } diff --git a/tests/SecurityExceptionTest.php b/tests/SecurityExceptionTest.php index 0ed935b..6e551a8 100644 --- a/tests/SecurityExceptionTest.php +++ b/tests/SecurityExceptionTest.php @@ -8,23 +8,30 @@ * @covers :: * @covers :: */ -class SecurityExceptionTest extends \PHPUnit_Framework_TestCase +class SecurityExceptionTest extends \PHPUnit\Framework\TestCase { /** * @covers ::__construct * @dataProvider securityExceptionCodes */ - public function testSecurityException(int $code) { + public function testSecurityException(int $code): void + { $ex = new SecurityException($code); - $this->assertInstanceOf(SecurityException::class, + $this->assertInstanceOf( + SecurityException::class, $ex, - '__construct failed'); - $this->assertNotEmpty($ex->getMessage(), - 'A predefined message should have been used'); + '__construct failed' + ); + $this->assertNotEmpty( + $ex->getMessage(), + 'A predefined message should have been used' + ); } // -( DataProviders )------------------------------------------------------ - public function securityExceptionCodes() { + /** @return array{int}[] */ + public function securityExceptionCodes() + { return [ [SecurityException::SIGNATURE_INVALID], [SecurityException::COUNTER_USED], @@ -33,5 +40,4 @@ public function securityExceptionCodes() { [SecurityException::NO_TRUSTED_CA], ]; } - } diff --git a/tests/ServerTest.php b/tests/ServerTest.php index aeba2cc..4185cdc 100644 --- a/tests/ServerTest.php +++ b/tests/ServerTest.php @@ -10,108 +10,180 @@ * @covers :: * @covers :: */ -class ServerTest extends \PHPUnit_Framework_TestCase +class ServerTest extends \PHPUnit\Framework\TestCase { - const APP_ID = 'https://u2f.example.com'; - const ENCODED_KEY_HANDLE = 'JUnVTStPn-V2-bCu0RlvPbukBpHTD5Mi1ZGglDOcN0vD45rnTD0BXdkRt78huTwJ7tVaxTqSetHjr22tCjmYLQ'; - const ENCODED_PUBLIC_KEY = 'BEyIn4ldTViNAgceMA/YgRX1DlJR3bSF39drG44Fx1E2LaF9Md9RUN2CHyfzSokIjjCHP8jMsTYwdt0tKe6qLzc='; + const APP_ID = 'https://u2f.ericstern.com'; + const ENCODED_KEY_HANDLE = + 'JUnVTStPn-V2-bCu0RlvPbukBpHTD5Mi1ZGglDOcN0vD45rnTD0BXdkRt78huTwJ7tVax'. + 'TqSetHjr22tCjmYLQ'; + + const ENCODED_PUBLIC_KEY = + 'BEyIn4ldTViNAgceMA/YgRX1DlJR3bSF39drG44Fx1E2LaF9Md9RUN2CHyfzSokIjjCHP'. + '8jMsTYwdt0tKe6qLzc='; + + /** @var Server */ private $server; - public function setUp() { + public function setUp(): void + { $this->server = (new Server()) ->disableCAVerification() ->setAppId(self::APP_ID); } + /** + * @covers ::__construct + */ + public function testConstruct(): void + { + $server = new Server(); + $this->assertInstanceOf(Server::class, $server); + } + /** * @covers ::disableCAVerification */ - public function testDisableCAVerificationReturnsSelf() { + public function testDisableCAVerificationReturnsSelf(): void + { $server = new Server(); - $this->assertSame($server, $server->disableCAVerification(), - 'disableCAVerification did not return $this'); + $this->assertSame( + $server, + $server->disableCAVerification(), + 'disableCAVerification did not return $this' + ); } /** * @covers ::generateRegisterRequest */ - public function testGenerateRegisterRequest() { + public function testGenerateRegisterRequest(): void + { $req = $this->server->generateRegisterRequest(); $this->assertInstanceOf(RegisterRequest::class, $req); - $this->assertSame(self::APP_ID, $req->getAppId(), - 'RegisterRequest App ID was not the value from the server'); - $this->assertNotEmpty($req->getChallenge(), - 'No challenge value was set'); - $this->assertTrue(strlen($req->getChallenge()) >= 8, - 'Challenge was less than 8 bytes long, violating the spec'); + $this->assertSame( + self::APP_ID, + $req->getAppId(), + 'RegisterRequest App ID was not the value from the server' + ); + $this->assertNotEmpty( + $req->getChallenge(), + 'No challenge value was set' + ); + $this->assertTrue( + strlen($req->getChallenge()) >= 8, + 'Challenge was less than 8 bytes long, violating the spec' + ); } /** * @covers ::generateSignRequest */ - public function testGenerateSignRequest() { + public function testGenerateSignRequest(): void + { $kh = \random_bytes(16); $registration = (new Registration()) ->setKeyHandle($kh); $req = $this->server->generateSignRequest($registration); $this->assertInstanceOf(SignRequest::class, $req); - $this->assertSame($kh, $req->getKeyHandleBinary(), - 'Key handle was not set correctly'); - $this->assertSame(self::APP_ID, $req->getAppId(), - 'SignRequest App ID was not the value form the server'); - $this->assertNotEmpty($req->getChallenge(), - 'No challenge value was set'); - $this->assertTrue(strlen($req->getChallenge()) >= 8, - 'Challenge was less than 8 bytes long, violating the spec'); + $this->assertSame( + $kh, + $req->getKeyHandleBinary(), + 'Key handle was not set correctly' + ); + $this->assertSame( + self::APP_ID, + $req->getAppId(), + 'SignRequest App ID was not the value form the server' + ); + $this->assertNotEmpty( + $req->getChallenge(), + 'No challenge value was set' + ); + $this->assertTrue( + strlen($req->getChallenge()) >= 8, + 'Challenge was less than 8 bytes long, violating the spec' + ); + } + + /** + * @covers ::generateSignRequests + */ + public function testGenerateSignRequests(): void + { + $registrations = [ + (new Registration())->setKeyHandle(\random_bytes(16)), + (new Registration())->setKeyHandle(\random_bytes(16)), + ]; + $signRequests = $this->server->generateSignRequests($registrations); + + $this->assertIsArray($signRequests); + foreach ($signRequests as $signRequest) { + $this->assertInstanceOf(SignRequest::class, $signRequest); + } + // This method is a simple map operation, so testGenerateSignRequest + // does the heavy lifting. } /** * @covers ::setRegisterRequest */ - public function testSetRegisterRequestReturnsSelf() { + public function testSetRegisterRequestReturnsSelf(): void + { $req = $this->getDefaultRegisterRequest(); - $this->assertSame($this->server, + $this->assertSame( + $this->server, $this->server->setRegisterRequest($req), - 'setRegisterRequest did not return $this'); + 'setRegisterRequest did not return $this' + ); } /** * @covers ::setRegistrations */ - public function testSetRegistrationsReturnsSelf() { + public function testSetRegistrationsReturnsSelf(): void + { $reg = $this->getDefaultRegistration(); - $this->assertSame($this->server, + $this->assertSame( + $this->server, $this->server->setRegistrations([$reg]), - 'setRegistrations did not return $this'); + 'setRegistrations did not return $this' + ); } /** * @covers ::setRegistrations */ - public function testSetRegistrationsEnforcesTypeCheck() { + public function testSetRegistrationsEnforcesTypeCheck(): void + { $wrong = true; $this->expectException(TypeError::class); + // @phpstan-ignore-next-line $this->server->setRegistrations([$wrong]); } /** * @covers ::setSignRequests */ - public function testSetSignRequestsReturnsSelf() { + public function testSetSignRequestsReturnsSelf(): void + { $req = $this->getDefaultSignRequest(); - $this->assertSame($this->server, + $this->assertSame( + $this->server, $this->server->setSignRequests([$req]), - 'setSignRequests did not return $this'); + 'setSignRequests did not return $this' + ); } /** * @covers ::setSignRequests */ - public function testSetSignRequestsEnforcesTypeCheck() { + public function testSetSignRequestsEnforcesTypeCheck(): void + { $wrong = true; $this->expectException(TypeError::class); + // @phpstan-ignore-next-line $this->server->setSignRequests([$wrong]); } @@ -120,7 +192,8 @@ public function testSetSignRequestsEnforcesTypeCheck() { /** * @covers ::register */ - public function testRegisterThrowsIfNoRegistrationRequestProvided() { + public function testRegisterThrowsIfNoRegistrationRequestProvided(): void + { $this->expectException(BadMethodCallException::class); $this->server->register($this->getDefaultRegisterResponse()); } @@ -128,35 +201,49 @@ public function testRegisterThrowsIfNoRegistrationRequestProvided() { /** * @covers ::register */ - public function testRegistration() { + public function testRegistration(): void + { $request = $this->getDefaultRegisterRequest(); $response = $this->getDefaultRegisterResponse(); $registration = $this->server ->setRegisterRequest($request) ->register($response); - $this->assertInstanceOf(Registration::class, $registration, - 'Server->register did not return a registration'); - $this->assertSame(0, $registration->getCounter(), - 'Counter should start at 0'); - - $this->assertSame($response->getAttestationCertificateBinary(), - $registration->getAttestationCertificateBinary(), - 'Attestation cert was not copied from response'); - - $this->assertSame($response->getKeyHandleBinary(), + $this->assertInstanceOf( + RegistrationInterface::class, + $registration, + 'Server->register did not return a registration' + ); + $this->assertSame( + 0, + $registration->getCounter(), + 'Counter should start at 0' + ); + + $this->assertSame( + $response->getAttestationCertificate()->getBinary(), + $registration->getAttestationCertificate()->getBinary(), + 'Attestation cert was not copied from response' + ); + + $this->assertSame( + $response->getKeyHandleBinary(), $registration->getKeyHandleBinary(), - 'Key handle was not copied from response'); + 'Key handle was not copied from response' + ); - $this->assertSame($response->getPublicKey(), - $registration->getPublicKey(), - 'Public key was not copied from response'); + $this->assertSame( + $response->getPublicKey()->getBinary(), + $registration->getPublicKey()->getBinary(), + 'Public key was not copied from response' + ); } /** * @covers ::register */ - public function testRegisterDefaultsToTryingEmptyCAList() { + public function testRegisterDefaultsToTryingEmptyCAList(): void + { $request = $this->getDefaultRegisterRequest(); $response = $this->getDefaultRegisterResponse(); @@ -174,7 +261,8 @@ public function testRegisterDefaultsToTryingEmptyCAList() { /** * @covers ::register */ - public function testRegisterThrowsIfChallengeDoesNotMatch() { + public function testRegisterThrowsIfChallengeDoesNotMatch(): void + { // This would have come from a session, database, etc. $request = (new RegisterRequest()) ->setAppId('https://u2f.ericstern.com') @@ -191,7 +279,8 @@ public function testRegisterThrowsIfChallengeDoesNotMatch() { /** * @covers ::register */ - public function testRegisterThrowsWithUntrustedDeviceIssuerCertificate() { + public function testRegisterThrowsWithUntrustedDeviceIssuerCertificate(): void + { $request = $this->getDefaultRegisterRequest(); $response = $this->getDefaultRegisterResponse(); @@ -211,7 +300,8 @@ public function testRegisterThrowsWithUntrustedDeviceIssuerCertificate() { * @covers ::register * @covers ::setTrustedCAs */ - public function testRegisterWorksWithCAList() { + public function testRegisterWorksWithCAList(): void + { $request = $this->getDefaultRegisterRequest(); $response = $this->getDefaultRegisterResponse(); // This contains the actual trusted + verified certificates which are @@ -219,10 +309,11 @@ public function testRegisterWorksWithCAList() { // generated with a YubiCo device and separately tested against // a different reference implementation. $CAs = glob(dirname(__DIR__).'/CAcerts/*.pem'); + assert($CAs !== false); $this->server->setTrustedCAs($CAs); try { - $this->server + $reg = $this->server ->setRegisterRequest($request) ->register($response); } catch (SecurityException $e) { @@ -231,21 +322,24 @@ public function testRegisterWorksWithCAList() { } throw $e; } - // Implicit pass - no exceptions should be thrown at all + $this->assertInstanceOf(RegistrationInterface::class, $reg); } /** * @covers ::register */ - public function testRegisterThrowsWithChangedApplicationParameter() { - $request = (new RegisterRequest()) - ->setAppId('https://not.my.u2f.example.com') - ->setChallenge('PfsWR1Umy2V5Al1Bam2tG0yfPLeJElfwRzzAzkYPgzo'); + public function testRegisterThrowsWithChangedApplicationParameter(): void + { + $request = $this->getDefaultRegisterRequest(); - $response = $this->getDefaultRegisterResponse(); + $response = $this->createMock(RegistrationResponseInterface::class); + $response->method('getChallenge') + ->willReturn($request->getChallenge()); + $response->method('getRpIdHash') + ->willReturn(hash('sha256', 'https://some.otherdomain.com', true)); $this->expectException(SecurityException::class); - $this->expectExceptionCode(SecurityException::SIGNATURE_INVALID); + $this->expectExceptionCode(SecurityException::WRONG_RELYING_PARTY); $this->server ->setRegisterRequest($request) ->register($response); @@ -254,17 +348,17 @@ public function testRegisterThrowsWithChangedApplicationParameter() { /** * @covers ::register */ - public function testRegisterThrowsWithChangedChallengeParameter() { + public function testRegisterThrowsWithChangedChallengeParameter(): void + { $request = $this->getDefaultRegisterRequest(); // Mess up some known-good data: challenge parameter - $json = file_get_contents(__DIR__.'/register_response.json'); - $data = json_decode($json, true); + $data = $this->readJsonFile('register_response.json'); $cli = fromBase64Web($data['clientData']); $obj = json_decode($cli, true); - $obj['origin'] = 'https://not.my.u2f.example.com'; - $cli = toBase64Web(json_encode($obj)); + $obj['cid_pubkey'] = 'nonsense'; + $cli = toBase64Web($this->safeEncode($obj)); $data['clientData'] = $cli; - $response = RegisterResponse::fromJson(json_encode($data)); + $response = RegisterResponse::fromJson($this->safeEncode($data)); $this->expectException(SecurityException::class); $this->expectExceptionCode(SecurityException::SIGNATURE_INVALID); @@ -276,15 +370,15 @@ public function testRegisterThrowsWithChangedChallengeParameter() { /** * @covers ::register */ - public function testRegisterThrowsWithChangedKeyHandle() { + public function testRegisterThrowsWithChangedKeyHandle(): void + { $request = $this->getDefaultRegisterRequest(); // Mess up some known-good data: key handle - $json = file_get_contents(__DIR__.'/register_response.json'); - $data = json_decode($json, true); + $data = $this->readJsonFile('register_response.json'); $reg = $data['registrationData']; - $reg[70] = $reg[70] ^ 0xFF; // Invert a byte in the key handle + $reg[70] = chr(ord($reg[70]) + 1); // Change a byte in the key handle $data['registrationData'] = $reg; - $response = RegisterResponse::fromJson(json_encode($data)); + $response = RegisterResponse::fromJson($this->safeEncode($data)); $this->expectException(SecurityException::class); $this->expectExceptionCode(SecurityException::SIGNATURE_INVALID); @@ -296,15 +390,15 @@ public function testRegisterThrowsWithChangedKeyHandle() { /** * @covers ::register */ - public function testRegisterThrowsWithChangedPubkey() { + public function testRegisterThrowsWithChangedPubkey(): void + { $request = $this->getDefaultRegisterRequest(); // Mess up some known-good data: public key - $json = file_get_contents(__DIR__.'/register_response.json'); - $data = json_decode($json, true); + $data = $this->readJsonFile('register_response.json'); $reg = $data['registrationData']; - $reg[3] = $reg[3] ^ 0xFF; // Invert a byte in the public key + $reg[3] = chr(ord($reg[3]) + 1); // Change a byte in the public key $data['registrationData'] = $reg; - $response = RegisterResponse::fromJson(json_encode($data)); + $response = RegisterResponse::fromJson($this->safeEncode($data)); $this->expectException(SecurityException::class); $this->expectExceptionCode(SecurityException::SIGNATURE_INVALID); @@ -316,15 +410,15 @@ public function testRegisterThrowsWithChangedPubkey() { /** * @covers ::register */ - public function testRegisterThrowsWithBadSignature() { + public function testRegisterThrowsWithBadSignature(): void + { $request = $this->getDefaultRegisterRequest(); // Mess up some known-good data: signature - $json = file_get_contents(__DIR__.'/register_response.json'); - $data = json_decode($json, true); + $data = $this->readJsonFile('register_response.json'); $reg = $data['registrationData']; $last = str_rot13(substr($reg, -5)); // rot13 a few chars in signature - $data['registrationData'] = substr($reg,0,-5).$last; - $response = RegisterResponse::fromJson(json_encode($data)); + $data['registrationData'] = substr($reg, 0, -5).$last; + $response = RegisterResponse::fromJson($this->safeEncode($data)); $this->expectException(SecurityException::class); $this->expectExceptionCode(SecurityException::SIGNATURE_INVALID); @@ -338,7 +432,8 @@ public function testRegisterThrowsWithBadSignature() { /** * @covers ::authenticate */ - public function testAuthenticateThrowsIfNoRegistrationsPresent() { + public function testAuthenticateThrowsIfNoRegistrationsPresent(): void + { $this->server->setSignRequests([$this->getDefaultSignRequest()]); $this->expectException(BadMethodCallException::class); $this->server->authenticate($this->getDefaultSignResponse()); @@ -347,7 +442,8 @@ public function testAuthenticateThrowsIfNoRegistrationsPresent() { /** * @covers ::authenticate */ - public function testAuthenticateThrowsIfNoSignRequestsPresent() { + public function testAuthenticateThrowsIfNoSignRequestsPresent(): void + { $this->server->setRegistrations([$this->getDefaultRegistration()]); $this->expectException(BadMethodCallException::class); $this->server->authenticate($this->getDefaultSignResponse()); @@ -356,7 +452,8 @@ public function testAuthenticateThrowsIfNoSignRequestsPresent() { /** * @covers ::authenticate */ - public function testAuthenticate() { + public function testAuthenticate(): void + { // All normal $registration = $this->getDefaultRegistration(); $request = $this->getDefaultSignRequest(); @@ -366,33 +463,23 @@ public function testAuthenticate() { ->setRegistrations([$registration]) ->setSignRequests([$request]) ->authenticate($response); - $this->assertInstanceOf(Registration::class, $return, - 'A successful authentication should have returned a Registration'); - $this->assertNotSame($registration, $return, - 'A new instance of Registration should have been returned'); - $this->assertSame($response->getCounter(), $return->getCounter(), - 'The new Registration\'s counter did not match the Response'); - } - - /** - * Re-run testAuthenticate after ensuring that mbstring.func_overload is - * being used - * - * @coversNothing - */ - public function testAuthenticateWithMultibyteSettings() - { - $overload = ini_get('mbstring.func_overload'); - if ($overload != 7) { - $cmd = 'php -d mbstring.func_overload=7 '. - implode(' ', $_SERVER['argv']); - $this->markTestSkipped(sprintf( - "mbstring.func_overload cannot be changed at runtime. Re-run ". - "this test with the following command:\n\n%s", - $cmd)); - } - - $this->testAuthenticate(); + $this->assertInstanceOf( + RegistrationInterface::class, + $return, + 'A successful authentication should have returned an object '. + 'implementing RegistrationInterface' + ); + $this->assertNotSame( + $registration, + $return, + 'A new object implementing RegistrationInterface should have been '. + 'returned' + ); + $this->assertSame( + $response->getCounter(), + $return->getCounter(), + 'The new registration\'s counter did not match the Response' + ); } /** @@ -401,7 +488,8 @@ public function testAuthenticateWithMultibyteSettings() * * @covers ::authenticate */ - public function testAuthenticateThrowsWithObviousReplayAttack() { + public function testAuthenticateThrowsWithObviousReplayAttack(): void + { // All normal $registration = $this->getDefaultRegistration(); $request = $this->getDefaultSignRequest(); @@ -428,11 +516,12 @@ public function testAuthenticateThrowsWithObviousReplayAttack() { /** * @covers ::authenticate */ - public function testAuthenticateThrowsWhenCounterGoesBackwards() { + public function testAuthenticateThrowsWhenCounterGoesBackwards(): void + { // Counter from "DB" bumped, suggesting response was cloned $registration = (new Registration()) ->setKeyHandle(fromBase64Web(self::ENCODED_KEY_HANDLE)) - ->setPublicKey(base64_decode(self::ENCODED_PUBLIC_KEY)) + ->setPublicKey($this->getDefaultPublicKey()) ->setCounter(82) ; $request = $this->getDefaultSignRequest(); @@ -449,7 +538,8 @@ public function testAuthenticateThrowsWhenCounterGoesBackwards() { /** * @covers ::authenticate */ - public function testAuthenticateThrowsWhenChallengeDoesNotMatch() { + public function testAuthenticateThrowsWhenChallengeDoesNotMatch(): void + { $registration = $this->getDefaultRegistration(); // Change request challenge $request = (new SignRequest()) @@ -470,11 +560,12 @@ public function testAuthenticateThrowsWhenChallengeDoesNotMatch() { /** * @covers ::authenticate */ - public function testAuthenticateThrowsIfNoRegistrationMatchesKeyHandle() { + public function testAuthenticateThrowsIfNoRegistrationMatchesKeyHandle(): void + { // Change registration KH $registration = (new Registration()) ->setKeyHandle(fromBase64Web('some-other-key-handle')) - ->setPublicKey(base64_decode(self::ENCODED_PUBLIC_KEY)) + ->setPublicKey($this->getDefaultPublicKey()) ->setCounter(2) ; $request = $this->getDefaultSignRequest(); @@ -491,7 +582,8 @@ public function testAuthenticateThrowsIfNoRegistrationMatchesKeyHandle() { /** * @covers ::authenticate */ - public function testAuthenticateThrowsIfNoRequestMatchesKeyHandle() { + public function testAuthenticateThrowsIfNoRequestMatchesKeyHandle(): void + { $registration = $this->getDefaultRegistration(); // Change request KH $request = (new SignRequest()) @@ -512,15 +604,14 @@ public function testAuthenticateThrowsIfNoRequestMatchesKeyHandle() { /** * @covers ::authenticate */ - public function testAuthenticateThrowsIfSignatureIsInvalid() { + public function testAuthenticateThrowsIfSignatureIsInvalid(): void + { $registration = $this->getDefaultRegistration(); $request = $this->getDefaultSignRequest(); // Trimming a byte off the signature to cause a mismatch - $data = json_decode( - file_get_contents(__DIR__.'/sign_response.json'), - true); + $data = $this->readJsonFile('sign_response.json'); $data['signatureData'] = substr($data['signatureData'], 0, -1); - $response = SignResponse::fromJson(json_encode($data)); + $response = SignResponse::fromJson($this->safeEncode($data)); $this->expectException(SecurityException::class); $this->expectExceptionCode(SecurityException::SIGNATURE_INVALID); @@ -537,7 +628,13 @@ public function testAuthenticateThrowsIfSignatureIsInvalid() { * * @covers ::authenticate */ - public function testAuthenticateThrowsIfRequestIsSignedWithWrongKey() { + public function testAuthenticateThrowsIfRequestIsSignedWithWrongKey(): void + { + $pk = base64_decode( + 'BCXk9bGiuzLRJaX6pFONm+twgIrDkOSNDdXgltt+KhOD'. + '9OxeRv2zYiz7SrVa8eb4LbGR9IDUE7gJySiiuQYWt1w=' + ); + assert($pk !== false); // This was a different key genearated with: // $ openssl ecparam -name prime256v1 -genkey -out private.pem // $ openssl ec -in private.pem -pubout -out public.pem @@ -545,9 +642,7 @@ public function testAuthenticateThrowsIfRequestIsSignedWithWrongKey() { // leading bytes are formatting; see ECPublicKeyTrait) $registration = (new Registration()) ->setKeyHandle(fromBase64Web(self::ENCODED_KEY_HANDLE)) - ->setPublicKey(base64_decode( - 'BCXk9bGiuzLRJaX6pFONm+twgIrDkOSNDdXgltt+KhOD'. - '9OxeRv2zYiz7SrVa8eb4LbGR9IDUE7gJySiiuQYWt1w=')) + ->setPublicKey(new ECPublicKey($pk)) ->setCounter(2) ; $request = $this->getDefaultSignRequest(); @@ -560,21 +655,89 @@ public function testAuthenticateThrowsIfRequestIsSignedWithWrongKey() { ->authenticate($response); } + // -( Alternate formats (see #14) )---------------------------------------- + + public function testRegistrationWithoutCidPubkeyBug14Case1(): void + { + $server = (new Server()) + ->disableCAVerification() + ->setAppId('https://u2f.ericstern.com'); + + $registerRequest = new RegisterRequest(); + $registerRequest->setAppId($server->getAppId()) + ->setChallenge('dNqjowssvlxx9zBhvsy03A'); + $server->setRegisterRequest($registerRequest); + + $json = '{"registrationData":"BQSFDYsZaHlRBQcdLyu4jZ-Bukb1vw6QtSfmvTQO'. + 'IXpjZpfqYptdtpBznuNBslzlZdodspfqRkqwJIt3a0W2P_HlQImHG1FoSkYdPwSzp'. + '3WvlDisShW5fveiaaI4Zk8oZBkyWoQ6v1c2ypcd5OWPX6rAH-N7cPjw1Vg_w1q_YL'. + 'c3mR8wggE0MIHboAMCAQICCjJ1rwmwx867ew8wCgYIKoZIzj0EAwIwFTETMBEGA1U'. + 'EAxMKVTJGIElzc3VlcjAaFwswMDAxMDEwMDAwWhcLMDAwMTAxMDAwMFowFTETMBEG'. + 'A1UEAxMKVTJGIERldmljZTBZMBMGByqGSM49AgEGCCqGSM49AwEHA0IABCLzJT4vt'. + 'kl-799Ks5wINHdVRIKCLq-kX6oIajh_2Dv4Sk0cBVteQt1xdGau1XzEaGYIOvU5hU'. + 'm2J2pxVBQIzaajFzAVMBMGCysGAQQBguUcAgEBBAQDAgUgMAoGCCqGSM49BAMCA0g'. + 'AMEUCIQDBo6aOLxanIUYnBX9iu3KMngPnobpi0EZSTkVtLC8_cwIgC1945RGqGBKf'. + 'byNtkhMifZK05n7fU-gW37Bdnci5D94wRQIgEPJVWZ7zgVQUctG3xpWBv77s3u2R7'. + 'OJP-UjkWdcUs2QCIQC1fqlZIrl4kIEsSQTRMauvcaoeunV-I24WYnp3rgC_Dg","v'. + 'ersion":"U2F_V2","challenge":"dNqjowssvlxx9zBhvsy03A","appId":"ht'. + 'tps://u2f.ericstern.com","clientData":"eyJjaGFsbGVuZ2UiOiJkTnFqb3'. + 'dzc3ZseHg5ekJodnN5MDNBIiwib3JpZ2luIjoiaHR0cHM6Ly91MmYuZXJpY3N0ZXJ'. + 'uLmNvbSIsInR5cCI6Im5hdmlnYXRvci5pZC5maW5pc2hFbnJvbGxtZW50In0"}'; + $registerResponse = RegisterResponse::fromJson($json); + + $registration = $server->register($registerResponse); + $this->assertInstanceOf(Registration::class, $registration); + } + + public function testRegistrationWithoutCidPubkeyBug14Case2(): void + { + $server = (new Server()) + ->disableCAVerification() + ->setAppId('https://u2f.ericstern.com'); + + $registerRequest = new RegisterRequest(); + $registerRequest->setAppId($server->getAppId()) + ->setChallenge('E23usdC7VkxjN1mwRAeyjg'); + $server->setRegisterRequest($registerRequest); + + $json = '{"registrationData":"BQSTffB-e9hdFwhsfb2t-2ppwyxZAltnDf6TYwv4'. + '1VtleEO4488JwNFGr_bks_4EzA4DoluDBCgfmULGpZpXykTZQMOMz9DfbESHnuBY9'. + 'cmTxVTVtrsTFTQA-IPETCYJ2dYACULXRN7_qLq_2WnDQJaME7zWyZEB0NFu-hosav'. + 'uqjncwggEbMIHCoAMCAQICCiIygbKxS2KpYY8wCgYIKoZIzj0EAwIwFTETMBEGA1U'. + 'EAxMKVTJGIElzc3VlcjAaFwswMDAxMDEwMDAwWhcLMDAwMTAxMDAwMFowFTETMBEG'. + 'A1UEAxMKVTJGIERldmljZTBZMBMGByqGSM49AgEGCCqGSM49AwEHA0IABCdqjfpHR'. + '9L8a6-pVRv9PWu-pORC9sO9eDk6ZlFIXaclyfxbLJqAehvIWJuzij_BxJOLbQPD_9'. + 'fX5uKh9tDv8nowCgYIKoZIzj0EAwIDSAAwRQIhAMGjpo4vFqchRicFf2K7coyeA-e'. + 'humLQRlJORW0sLz9zAiALX3jlEaoYEp9vI22SEyJ9krTmft9T6BbfsF2dyLkP3jBE'. + 'AiAHD70-wA4f3SZk6s0RocHAA4nDCGaVFvTBG4gZXcZTnQIge2joenpQxVP0r1o9E'. + 'zL9C3aR-HEKhSHr86MX4eUTMlw","version":"U2F_V2","challenge":"E23us'. + 'dC7VkxjN1mwRAeyjg","appId":"https://u2f.ericstern.com","clientDat'. + 'a":"eyJjaGFsbGVuZ2UiOiJFMjN1c2RDN1ZreGpOMW13UkFleWpnIiwib3JpZ2luI'. + 'joiaHR0cHM6Ly91MmYuZXJpY3N0ZXJuLmNvbSIsInR5cCI6Im5hdmlnYXRvci5pZC'. + '5maW5pc2hFbnJvbGxtZW50In0"}'; + $registerResponse = RegisterResponse::fromJson($json); + + $registration = $server->register($registerResponse); + $this->assertInstanceOf(Registration::class, $registration); + } + // -( Helpers )------------------------------------------------------------ - private function getDefaultRegisterRequest(): RegisterRequest { + private function getDefaultRegisterRequest(): RegisterRequest + { // This would have come from a session, database, etc. return (new RegisterRequest()) ->setAppId('https://u2f.ericstern.com') ->setChallenge('PfsWR1Umy2V5Al1Bam2tG0yfPLeJElfwRzzAzkYPgzo'); } - private function getDefaultRegisterResponse(): RegisterResponse { - return RegisterResponse::fromJson( - file_get_contents(__DIR__.'/register_response.json')); + private function getDefaultRegisterResponse(): RegisterResponse + { + return RegisterResponse::fromJson($this->safeReadFile('register_response.json')); } - private function getDefaultSignRequest(): SignRequest { + private function getDefaultSignRequest(): SignRequest + { // This would have come from a session, database, etc return (new SignRequest()) ->setAppId('https://u2f.ericstern.com') @@ -583,18 +746,82 @@ private function getDefaultSignRequest(): SignRequest { ; } - private function getDefaultRegistration(): Registration { + private function getDefaultRegistration(): RegistrationInterface + { // From database attached to the authenticating user return (new Registration()) ->setKeyHandle(fromBase64Web(self::ENCODED_KEY_HANDLE)) - ->setPublicKey(base64_decode(self::ENCODED_PUBLIC_KEY)) + ->setAttestationCertificate($this->getDefaultAttestationCertificate()) + ->setPublicKey($this->getDefaultPublicKey()) ->setCounter(2) ; } - private function getDefaultSignResponse(): SignResponse { + private function getDefaultSignResponse(): SignResponse + { // Value from user - return SignResponse::fromJson( - file_get_contents(__DIR__.'/sign_response.json')); + return SignResponse::fromJson($this->safeReadFile('sign_response.json')); + } + + private function getDefaultAttestationCertificate(): AttestationCertificate + { + $attest = hex2bin( + '3082022d30820117a003020102020405b60579300b06092a864886f70d01010b'. + '302e312c302a0603550403132359756269636f2055324620526f6f7420434120'. + '53657269616c203435373230303633313020170d313430383031303030303030'. + '5a180f32303530303930343030303030305a30283126302406035504030c1d59'. + '756269636f205532462045452053657269616c20393538313530333330593013'. + '06072a8648ce3d020106082a8648ce3d03010703420004fdb8deb3a1ed70eb63'. + '6c066eb6006996a5f970fcb5db88fc3b305d41e5966f0c1b54b852fef0a0907e'. + 'd17f3bffc29d4d321b9cf8a84a2ceaa038cabd35d598dea3263024302206092b'. + '0601040182c40a020415312e332e362e312e342e312e34313438322e312e3130'. + '0b06092a864886f70d01010b03820101007ed3fb6ccc252013f82f218c2a37da'. + '6031d20e7f3081dafcaeb128fc7f9b233914bfb64d6135f17ce221fa764f453e'. + 'f1273a8ce965956442bb2f1e47483f737dcbc98b585377fef50b270e0289f884'. + '36f1adcf49b2621ee5e302df555b9ab74272e069f918149b3dec4f12228b10c0'. + 'f88de36af58a74bb442b85ae005364bda6702058fc1f2d879b530111ea60e86c'. + '63f17fa5944cc83f0aa269848b3ee388a6c09e6b05953fcbb8f47e83a27e0072'. + 'a63c32ad64864e926d7112fa1997f7839656fbb32be8f7889d0f0145519a27af'. + 'dd8e46b04ca4290d8540b634b886161e7588c86299dcdd6435d1678a3a6f0a74'. + '829c4dd3f70c3524d1ddf16d78add21b64' + ); + assert($attest !== false); + return new AttestationCertificate($attest); + } + + public function getDefaultPublicKey(): PublicKeyInterface + { + $pk = base64_decode(self::ENCODED_PUBLIC_KEY); + assert($pk !== false); + return new ECPublicKey($pk); + } + + /** @return mixed[] */ + private function readJsonFile(string $file): array + { + return $this->safeDecode($this->safeReadFile($file)); + } + + private function safeReadFile(string $file): string + { + $body = file_get_contents(__DIR__.'/'.$file); + assert($body !== false); + return $body; + } + + /** @return mixed[] */ + private function safeDecode(string $json): array + { + $data = json_decode($json, true); + assert($data !== false); + return $data; + } + + /** @param mixed[] $data */ + private function safeEncode(array $data): string + { + $json = json_encode($data); + assert($json !== false); + return $json; } } diff --git a/tests/SignRequestTest.php b/tests/SignRequestTest.php index 1959c75..c2997c2 100644 --- a/tests/SignRequestTest.php +++ b/tests/SignRequestTest.php @@ -8,12 +8,13 @@ * @covers :: * @covers :: */ -class SignRequestTest extends \PHPUnit_Framework_TestCase +class SignRequestTest extends \PHPUnit\Framework\TestCase { /** * @covers ::jsonSerialize */ - public function testJsonSerialize() { + public function testJsonSerialize(): void + { $appId = 'https://u2f.example.com'; $challenge = 'some-random-string'; $keyHandle = random_bytes(20); @@ -22,30 +23,57 @@ public function testJsonSerialize() { $request ->setAppId($appId) ->setChallenge($challenge) - ->setKeyHandle($keyHandle);; + ->setKeyHandle($keyHandle); $json = json_encode($request); + assert($json !== false); $decoded = json_decode($json, true); - $this->assertSame($appId, $request->getAppId(), - 'getAppId returned the wrong value'); - $this->assertSame($appId, $decoded['appId'], - 'json appId property did not match'); + $this->assertSame( + $appId, + $request->getAppId(), + 'getAppId returned the wrong value' + ); + $this->assertSame( + $appId, + $decoded['appId'], + 'json appId property did not match' + ); - $this->assertSame($challenge, $request->getChallenge(), - 'getChallenge returned the wrong value'); - $this->assertSame($challenge, $decoded['challenge'], - 'json challenge property did not match'); + $this->assertSame( + $challenge, + $request->getChallenge(), + 'getChallenge returned the wrong value' + ); + $this->assertSame( + $challenge, + $decoded['challenge'], + 'json challenge property did not match' + ); - $this->assertSame($keyHandle, $request->getKeyHandleBinary(), - 'getKeyHandleBinary returned the wrong value'); - $this->assertSame(toBase64Web($keyHandle), $request->getKeyHandleWeb(), - 'getKeyHandleWeb returned the wrong value'); - $this->assertSame(toBase64Web($keyHandle), $decoded['keyHandle'], - 'json keyHandle property did not match'); + $this->assertSame( + $keyHandle, + $request->getKeyHandleBinary(), + 'getKeyHandleBinary returned the wrong value' + ); + $this->assertSame( + toBase64Web($keyHandle), + $request->getKeyHandleWeb(), + 'getKeyHandleWeb returned the wrong value' + ); + $this->assertSame( + toBase64Web($keyHandle), + $decoded['keyHandle'], + 'json keyHandle property did not match' + ); - $this->assertSame('U2F_V2', $request->getVersion(), - 'getVersion returned the wrong value'); - $this->assertSame('U2F_V2', $decoded['version'], - 'json version was incorrect'); + $this->assertSame( + 'U2F_V2', + $request->getVersion(), + 'getVersion returned the wrong value' + ); + $this->assertSame( + 'U2F_V2', + $decoded['version'], + 'json version was incorrect' + ); } - } diff --git a/tests/SignResponseTest.php b/tests/SignResponseTest.php index cee3fb8..58f0839 100644 --- a/tests/SignResponseTest.php +++ b/tests/SignResponseTest.php @@ -8,22 +8,38 @@ * @covers :: * @covers :: */ -class SignResponseTest extends \PHPUnit_Framework_TestCase +class SignResponseTest extends \PHPUnit\Framework\TestCase { const JSON_FORMAT = '{"keyHandle":"%s","clientData":"%s","signatureData":"%s"}'; - private $valid_key_handle = 'JUnVTStPn-V2-bCu0RlvPbukBpHTD5Mi1ZGglDOcN0vD45rnTD0BXdkRt78huTwJ7tVaxTqSetHjr22tCjmYLQ'; - private $valid_client_data = 'eyJ0eXAiOiJuYXZpZ2F0b3IuaWQuZ2V0QXNzZXJ0aW9uIiwiY2hhbGxlbmdlIjoid3QyemU4SXNrY1RPM25Jc08yRDJoRmpFNXRWRDA0MU5wblllc0xwSndlZyIsIm9yaWdpbiI6Imh0dHBzOi8vdTJmLmVyaWNzdGVybi5jb20iLCJjaWRfcHVia2V5IjoiIn0'; - private $valid_signature_data = 'AQAAAC0wRgIhAJPy1RvD1WCw1XZX53BXydX_Kyf_XZQueFSIPigRF-D2AiEAx3bJr5ixrXGdUX1XooAfhz15ZIY8rC5H4qaW7gQspJ4'; + + /** @var string */ + private $valid_key_handle = + 'JUnVTStPn-V2-bCu0RlvPbukBpHTD5Mi1ZGglDOcN0vD45rnTD0BXdkRt78huTwJ7tVax'. + 'TqSetHjr22tCjmYLQ'; + + /** @var string */ + private $valid_client_data = + 'eyJ0eXAiOiJuYXZpZ2F0b3IuaWQuZ2V0QXNzZXJ0aW9uIiwiY2hhbGxlbmdlIjoid3Qye'. + 'mU4SXNrY1RPM25Jc08yRDJoRmpFNXRWRDA0MU5wblllc0xwSndlZyIsIm9yaWdpbiI6Im'. + 'h0dHBzOi8vdTJmLmVyaWNzdGVybi5jb20iLCJjaWRfcHVia2V5IjoiIn0'; + + /** @var string */ + private $valid_signature_data = + 'AQAAAC0wRgIhAJPy1RvD1WCw1XZX53BXydX_Kyf_XZQueFSIPigRF-D2AiEAx3bJr5ixr'. + 'XGdUX1XooAfhz15ZIY8rC5H4qaW7gQspJ4'; /** * @covers ::fromJson */ - public function testFromJsonWorks() { - $json = sprintf(self::JSON_FORMAT, + public function testFromJsonWorks(): void + { + $json = sprintf( + self::JSON_FORMAT, $this->valid_key_handle, $this->valid_client_data, - $this->valid_signature_data); + $this->valid_signature_data + ); $response = SignResponse::fromJson($json); $this->assertInstanceOf(SignResponse::class, $response); } @@ -33,107 +49,202 @@ public function testFromJsonWorks() { * @covers ::getSignature * @covers ::getUserPresenceByte */ - public function testDataAccuracyAfterSuccessfulParsing() { + public function testDataAccuracyAfterSuccessfulParsing(): void + { $sig = random_bytes(16); - $counter = random_int(0, pow(2,32)); + $counter = random_int(0, pow(2, 32)); $signature_data = toBase64Web(pack('CNA*', 1, $counter, $sig)); $challenge = toBase64Web(random_bytes(32)); $key_handle = toBase64Web(random_bytes(16)); - $client_data = toBase64Web(json_encode([ + $json = json_encode([ "typ" => "navigator.id.getAssertion", "challenge" => $challenge, "origin" => "https://u2f.example.com", "cid_pubkey" => "" - ])); + ]); + assert($json !== false); + $client_data = toBase64Web($json); - $json = sprintf(self::JSON_FORMAT, + $json = sprintf( + self::JSON_FORMAT, $key_handle, $client_data, - $signature_data); + $signature_data + ); $response = SignResponse::fromJson($json); - $this->assertSame($key_handle, $response->getKeyHandleWeb(), - 'Key Handle was parsed incorrectly'); - $this->assertSame($counter, $response->getCounter(), - 'Counter was parsed incorrectly'); - $this->assertSame(1, $response->getUserPresenceByte(), - 'User presence byte was parsed incorrectly'); - $this->assertSame($sig, $response->getSignature(), - 'Signature was parsed incorrectly'); + $this->assertSame( + $key_handle, + $response->getKeyHandleWeb(), + 'Key Handle was parsed incorrectly' + ); + $this->assertSame( + $counter, + $response->getCounter(), + 'Counter was parsed incorrectly' + ); + $this->assertSame( + 1, + $response->getUserPresenceByte(), + 'User presence byte was parsed incorrectly' + ); + $this->assertSame( + $sig, + $response->getSignature(), + 'Signature was parsed incorrectly' + ); } - public function testSignatureWithNullRemainsIntact() { + public function testSignatureWithNullRemainsIntact(): void + { $sig = "\x00\x00\x00".random_bytes(10)."\x00\x00\x00"; $sigData = toBase64Web("\x01\x00\x00\x00\x45".$sig); - $json = sprintf(self::JSON_FORMAT, + $json = sprintf( + self::JSON_FORMAT, $this->valid_key_handle, $this->valid_client_data, - $sigData); + $sigData + ); $response = SignResponse::fromJson($json); - $this->assertSame($sig, $response->getSignature(), - 'Signature trimmed a trailing NUL byte'); + $this->assertSame( + $sig, + $response->getSignature(), + 'Signature trimmed a trailing NUL byte' + ); } - public function testSignatureWithSpaceRemainsIntact() { + public function testSignatureWithSpaceRemainsIntact(): void + { $sig = ' '.random_bytes(10).' '; $sigData = toBase64Web("\x01\x00\x00\x00\x45".$sig); - $json = sprintf(self::JSON_FORMAT, + $json = sprintf( + self::JSON_FORMAT, $this->valid_key_handle, $this->valid_client_data, - $sigData); + $sigData + ); $response = SignResponse::fromJson($json); - $this->assertSame($sig, $response->getSignature(), - 'Signature trimmed a trailing space'); + $this->assertSame( + $sig, + $response->getSignature(), + 'Signature trimmed a trailing space' + ); } - public function testFromJsonWithMissingKeyHandle() { - $json = sprintf('{"clientData":"%s","signatureData":"%s"}', - $this->valid_client_data, $this->valid_signature_data); + public function testFromJsonWithMissingKeyHandle(): void + { + $json = sprintf( + '{"clientData":"%s","signatureData":"%s"}', + $this->valid_client_data, + $this->valid_signature_data + ); $this->expectException(InvalidDataException::class); $this->expectExceptionCode(InvalidDataException::MISSING_KEY); SignResponse::fromJson($json); } - public function testFromJsonWithMissingClientData() { - $json = sprintf('{"keyHandle":"%s","signatureData":"%s"}', - $this->valid_key_handle, $this->valid_signature_data); + public function testFromJsonWithMissingClientData(): void + { + $json = sprintf( + '{"keyHandle":"%s","signatureData":"%s"}', + $this->valid_key_handle, + $this->valid_signature_data + ); $this->expectException(InvalidDataException::class); $this->expectExceptionCode(InvalidDataException::MISSING_KEY); SignResponse::fromJson($json); } - public function testFromJsonWithMissingSignatureData() { - $json = sprintf('{"keyHandle":"%s","clientData":"%s"}', - $this->valid_key_handle, $this->valid_client_data); + public function testFromJsonWithMissingSignatureData(): void + { + $json = sprintf( + '{"keyHandle":"%s","clientData":"%s"}', + $this->valid_key_handle, + $this->valid_client_data + ); $this->expectException(InvalidDataException::class); $this->expectExceptionCode(InvalidDataException::MISSING_KEY); SignResponse::fromJson($json); } - public function testFromJsonWithInvalidSignatureData() { - $json = sprintf('{"keyHandle":"%s","clientData":"%s","signatureData":"%s"}', + public function testFromJsonWithInvalidSignatureData(): void + { + $json = sprintf( + '{"keyHandle":"%s","clientData":"%s","signatureData":"%s"}', $this->valid_key_handle, $this->valid_client_data, - '0000000'); + '0000000' + ); $this->expectException(InvalidDataException::class); $this->expectExceptionCode(InvalidDataException::MALFORMED_DATA); SignResponse::fromJson($json); } + /** + * @covers ::getSignedData + */ + public function testGetSignedData(): void + { + $json = file_get_contents(__DIR__ . '/sign_response.json'); + assert($json !== false); + $response = SignResponse::fromJson($json); + + $expectedSignedData = sprintf( + '%s%s%s%s', + hash('sha256', 'https://u2f.ericstern.com', true), + "\x01", // user presence + "\x00\x00\x00\x2d", // counter (int(45)) + hash( + 'sha256', + '{'. + '"typ":"navigator.id.getAssertion",'. + '"challenge":"wt2ze8IskcTO3nIsO2D2hFjE5tVD041NpnYesLpJweg",'. + '"origin":"https://u2f.ericstern.com",'. + '"cid_pubkey":""'. + '}', + true + ) + ); + + $this->assertSame( + $expectedSignedData, + $response->getSignedData(), + 'Wrong signed data' + ); + } + + /** + * @covers ::getChallenge + */ + public function testGetChallenge(): void + { + $json = file_get_contents(__DIR__ . '/sign_response.json'); + assert($json !== false); + $response = SignResponse::fromJson($json); + + $this->assertSame( + 'wt2ze8IskcTO3nIsO2D2hFjE5tVD041NpnYesLpJweg', + $response->getChallenge() + ); + } + /** * @dataProvider clientErrors */ - public function testErrorResponse(int $code) { + public function testErrorResponse(int $code): void + { $json = sprintf('{"errorCode":%d}', $code); $this->expectException(ClientErrorException::class); $this->expectExceptionCode($code); SignResponse::fromJson($json); } - public function clientErrors() { + /** @return array{int}[] */ + public function clientErrors() + { return [ [ClientError::OTHER_ERROR], [ClientError::BAD_REQUEST], @@ -142,5 +253,4 @@ public function clientErrors() { [ClientError::TIMEOUT], ]; } - } diff --git a/tests/VersionTraitTest.php b/tests/VersionTraitTest.php index 154f415..03ba974 100644 --- a/tests/VersionTraitTest.php +++ b/tests/VersionTraitTest.php @@ -8,19 +8,21 @@ * @covers :: * @covers :: */ -class VersionTraitTest extends \PHPUnit_Framework_TestCase +class VersionTraitTest extends \PHPUnit\Framework\TestCase { /** * @covers ::getVersion */ - public function testGetVersion() { + public function testGetVersion(): void + { $obj = new class { use VersionTrait; }; - $this->assertSame('U2F_V2', $obj->getVersion(), - 'getVersion should always return the string "U2F_V2" per the spec'); + $this->assertSame( + 'U2F_V2', + $obj->getVersion(), + 'getVersion should always return the string "U2F_V2" per the spec' + ); } - - }