diff --git a/README.md b/README.md index aae345aa..99bb2243 100644 --- a/README.md +++ b/README.md @@ -21,7 +21,7 @@ WordPress.org-specific customizations for the Two Factor plugin return in_array( $user->user_login, $GLOBALS['supes'], true ); } ``` -1. Install and build the `wporg-mu-plugins` repository. +1. `git clone https://github.com/WordPress/wporg-mu-plugins.git --branch build wp-content/mu-plugins/pub` 1. Add this code to your `wp-content/mu-plugins/0-sandbox.php`: ```php require_once WPMU_PLUGIN_DIR. '/wporg-mu-plugins/mu-plugins/loader.php'; diff --git a/composer.json b/composer.json index ed143dcc..c7d1ccfe 100644 --- a/composer.json +++ b/composer.json @@ -13,7 +13,9 @@ "dealerdirect/phpcodesniffer-composer-installer": true } }, - "require": {}, + "require": { + "madwizard/webauthn": "~0.9.1" + }, "require-dev" : { "dealerdirect/phpcodesniffer-composer-installer": "^0.7.0", "wp-coding-standards/wpcs": "2.3.*", diff --git a/composer.lock b/composer.lock index 6285e578..57558c66 100644 --- a/composer.lock +++ b/composer.lock @@ -4,8 +4,1731 @@ "Read more about it at https://getcomposer.org/doc/01-basic-usage.md#installing-dependencies", "This file is @generated automatically" ], - "content-hash": "f40a9038bd081a0cf0e14c2a33bc2ce6", - "packages": [], + "content-hash": "6f7bae06dff1744a9ac41ed2d0de5d82", + "packages": [ + { + "name": "guzzlehttp/guzzle", + "version": "7.5.1", + "source": { + "type": "git", + "url": "https://github.com/guzzle/guzzle.git", + "reference": "b964ca597e86b752cd994f27293e9fa6b6a95ed9" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/guzzle/guzzle/zipball/b964ca597e86b752cd994f27293e9fa6b6a95ed9", + "reference": "b964ca597e86b752cd994f27293e9fa6b6a95ed9", + "shasum": "" + }, + "require": { + "ext-json": "*", + "guzzlehttp/promises": "^1.5", + "guzzlehttp/psr7": "^1.9.1 || ^2.4.5", + "php": "^7.2.5 || ^8.0", + "psr/http-client": "^1.0", + "symfony/deprecation-contracts": "^2.2 || ^3.0" + }, + "provide": { + "psr/http-client-implementation": "1.0" + }, + "require-dev": { + "bamarni/composer-bin-plugin": "^1.8.1", + "ext-curl": "*", + "php-http/client-integration-tests": "^3.0", + "phpunit/phpunit": "^8.5.29 || ^9.5.23", + "psr/log": "^1.1 || ^2.0 || ^3.0" + }, + "suggest": { + "ext-curl": "Required for CURL handler support", + "ext-intl": "Required for Internationalized Domain Name (IDN) support", + "psr/log": "Required for using the Log middleware" + }, + "type": "library", + "extra": { + "bamarni-bin": { + "bin-links": true, + "forward-command": false + }, + "branch-alias": { + "dev-master": "7.5-dev" + } + }, + "autoload": { + "files": [ + "src/functions_include.php" + ], + "psr-4": { + "GuzzleHttp\\": "src/" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Graham Campbell", + "email": "hello@gjcampbell.co.uk", + "homepage": "https://github.com/GrahamCampbell" + }, + { + "name": "Michael Dowling", + "email": "mtdowling@gmail.com", + "homepage": "https://github.com/mtdowling" + }, + { + "name": "Jeremy Lindblom", + "email": "jeremeamia@gmail.com", + "homepage": "https://github.com/jeremeamia" + }, + { + "name": "George Mponos", + "email": "gmponos@gmail.com", + "homepage": "https://github.com/gmponos" + }, + { + "name": "Tobias Nyholm", + "email": "tobias.nyholm@gmail.com", + "homepage": "https://github.com/Nyholm" + }, + { + "name": "Márk Sági-Kazár", + "email": "mark.sagikazar@gmail.com", + "homepage": "https://github.com/sagikazarmark" + }, + { + "name": "Tobias Schultze", + "email": "webmaster@tubo-world.de", + "homepage": "https://github.com/Tobion" + } + ], + "description": "Guzzle is a PHP HTTP client library", + "keywords": [ + "client", + "curl", + "framework", + "http", + "http client", + "psr-18", + "psr-7", + "rest", + "web service" + ], + "support": { + "issues": "https://github.com/guzzle/guzzle/issues", + "source": "https://github.com/guzzle/guzzle/tree/7.5.1" + }, + "funding": [ + { + "url": "https://github.com/GrahamCampbell", + "type": "github" + }, + { + "url": "https://github.com/Nyholm", + "type": "github" + }, + { + "url": "https://tidelift.com/funding/github/packagist/guzzlehttp/guzzle", + "type": "tidelift" + } + ], + "time": "2023-04-17T16:30:08+00:00" + }, + { + "name": "guzzlehttp/promises", + "version": "1.5.2", + "source": { + "type": "git", + "url": "https://github.com/guzzle/promises.git", + "reference": "b94b2807d85443f9719887892882d0329d1e2598" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/guzzle/promises/zipball/b94b2807d85443f9719887892882d0329d1e2598", + "reference": "b94b2807d85443f9719887892882d0329d1e2598", + "shasum": "" + }, + "require": { + "php": ">=5.5" + }, + "require-dev": { + "symfony/phpunit-bridge": "^4.4 || ^5.1" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-master": "1.5-dev" + } + }, + "autoload": { + "files": [ + "src/functions_include.php" + ], + "psr-4": { + "GuzzleHttp\\Promise\\": "src/" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Graham Campbell", + "email": "hello@gjcampbell.co.uk", + "homepage": "https://github.com/GrahamCampbell" + }, + { + "name": "Michael Dowling", + "email": "mtdowling@gmail.com", + "homepage": "https://github.com/mtdowling" + }, + { + "name": "Tobias Nyholm", + "email": "tobias.nyholm@gmail.com", + "homepage": "https://github.com/Nyholm" + }, + { + "name": "Tobias Schultze", + "email": "webmaster@tubo-world.de", + "homepage": "https://github.com/Tobion" + } + ], + "description": "Guzzle promises library", + "keywords": [ + "promise" + ], + "support": { + "issues": "https://github.com/guzzle/promises/issues", + "source": "https://github.com/guzzle/promises/tree/1.5.2" + }, + "funding": [ + { + "url": "https://github.com/GrahamCampbell", + "type": "github" + }, + { + "url": "https://github.com/Nyholm", + "type": "github" + }, + { + "url": "https://tidelift.com/funding/github/packagist/guzzlehttp/promises", + "type": "tidelift" + } + ], + "time": "2022-08-28T14:55:35+00:00" + }, + { + "name": "guzzlehttp/psr7", + "version": "2.5.0", + "source": { + "type": "git", + "url": "https://github.com/guzzle/psr7.git", + "reference": "b635f279edd83fc275f822a1188157ffea568ff6" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/guzzle/psr7/zipball/b635f279edd83fc275f822a1188157ffea568ff6", + "reference": "b635f279edd83fc275f822a1188157ffea568ff6", + "shasum": "" + }, + "require": { + "php": "^7.2.5 || ^8.0", + "psr/http-factory": "^1.0", + "psr/http-message": "^1.1 || ^2.0", + "ralouphie/getallheaders": "^3.0" + }, + "provide": { + "psr/http-factory-implementation": "1.0", + "psr/http-message-implementation": "1.0" + }, + "require-dev": { + "bamarni/composer-bin-plugin": "^1.8.1", + "http-interop/http-factory-tests": "^0.9", + "phpunit/phpunit": "^8.5.29 || ^9.5.23" + }, + "suggest": { + "laminas/laminas-httphandlerrunner": "Emit PSR-7 responses" + }, + "type": "library", + "extra": { + "bamarni-bin": { + "bin-links": true, + "forward-command": false + } + }, + "autoload": { + "psr-4": { + "GuzzleHttp\\Psr7\\": "src/" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Graham Campbell", + "email": "hello@gjcampbell.co.uk", + "homepage": "https://github.com/GrahamCampbell" + }, + { + "name": "Michael Dowling", + "email": "mtdowling@gmail.com", + "homepage": "https://github.com/mtdowling" + }, + { + "name": "George Mponos", + "email": "gmponos@gmail.com", + "homepage": "https://github.com/gmponos" + }, + { + "name": "Tobias Nyholm", + "email": "tobias.nyholm@gmail.com", + "homepage": "https://github.com/Nyholm" + }, + { + "name": "Márk Sági-Kazár", + "email": "mark.sagikazar@gmail.com", + "homepage": "https://github.com/sagikazarmark" + }, + { + "name": "Tobias Schultze", + "email": "webmaster@tubo-world.de", + "homepage": "https://github.com/Tobion" + }, + { + "name": "Márk Sági-Kazár", + "email": "mark.sagikazar@gmail.com", + "homepage": "https://sagikazarmark.hu" + } + ], + "description": "PSR-7 message implementation that also provides common utility methods", + "keywords": [ + "http", + "message", + "psr-7", + "request", + "response", + "stream", + "uri", + "url" + ], + "support": { + "issues": "https://github.com/guzzle/psr7/issues", + "source": "https://github.com/guzzle/psr7/tree/2.5.0" + }, + "funding": [ + { + "url": "https://github.com/GrahamCampbell", + "type": "github" + }, + { + "url": "https://github.com/Nyholm", + "type": "github" + }, + { + "url": "https://tidelift.com/funding/github/packagist/guzzlehttp/psr7", + "type": "tidelift" + } + ], + "time": "2023-04-17T16:11:26+00:00" + }, + { + "name": "kevinrob/guzzle-cache-middleware", + "version": "v3.5.0", + "source": { + "type": "git", + "url": "https://github.com/Kevinrob/guzzle-cache-middleware.git", + "reference": "c699f4623a7be6c92468876a1b2404d499a4f22f" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/Kevinrob/guzzle-cache-middleware/zipball/c699f4623a7be6c92468876a1b2404d499a4f22f", + "reference": "c699f4623a7be6c92468876a1b2404d499a4f22f", + "shasum": "" + }, + "require": { + "guzzlehttp/guzzle": "^6.0 || ^7.0", + "guzzlehttp/psr7": "^1.7.0 || ^2.0.0", + "php": ">=5.5.0" + }, + "require-dev": { + "cache/array-adapter": "^0.4 || ^0.5 || ^1.0", + "cache/simple-cache-bridge": "^0.1 || ^1.0", + "doctrine/cache": "^1.0", + "illuminate/cache": "^5.0", + "league/flysystem": "^1.0", + "phpunit/phpunit": "^4.8.36 || ^5.0", + "psr/cache": "^1.0", + "symfony/phpunit-bridge": "^4.4 || ^5.0" + }, + "suggest": { + "doctrine/cache": "This library has a lot of ready-to-use cache storage (to be used with Kevinrob\\GuzzleCache\\Storage\\DoctrineCacheStorage).", + "guzzlehttp/guzzle": "For using this library. It was created for Guzzle6 (but you can use it with any PSR-7 HTTP client).", + "laravel/framework": "To be used with Kevinrob\\GuzzleCache\\Storage\\LaravelCacheStorage", + "league/flysystem": "To be used with Kevinrob\\GuzzleCache\\Storage\\FlysystemStorage", + "psr/cache": "To be used with Kevinrob\\GuzzleCache\\Storage\\Psr6CacheStorage", + "psr/simple-cache": "To be used with Kevinrob\\GuzzleCache\\Storage\\Psr16CacheStorage" + }, + "type": "library", + "autoload": { + "psr-4": { + "Kevinrob\\GuzzleCache\\": "src/" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Kevin Robatel", + "email": "kevinrob2@gmail.com", + "homepage": "https://github.com/Kevinrob" + } + ], + "description": "A HTTP/1.1 Cache for Guzzle 6. It's a simple Middleware to be added in the HandlerStack. (RFC 7234)", + "homepage": "https://github.com/Kevinrob/guzzle-cache-middleware", + "keywords": [ + "Etag", + "Flysystem", + "Guzzle", + "cache", + "cache-control", + "doctrine", + "expiration", + "guzzle6", + "handler", + "http", + "http 1.1", + "middleware", + "performance", + "php", + "promise", + "psr6", + "psr7", + "rfc7234", + "validation" + ], + "support": { + "issues": "https://github.com/Kevinrob/guzzle-cache-middleware/issues", + "source": "https://github.com/Kevinrob/guzzle-cache-middleware/tree/v3.5.0" + }, + "time": "2022-01-04T16:40:46+00:00" + }, + { + "name": "madwizard/webauthn", + "version": "v0.9.1", + "source": { + "type": "git", + "url": "https://github.com/madwizard-org/webauthn-server.git", + "reference": "0e6ee14ee4bbe5ec97f0f868c9972c163b0c95f7" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/madwizard-org/webauthn-server/zipball/0e6ee14ee4bbe5ec97f0f868c9972c163b0c95f7", + "reference": "0e6ee14ee4bbe5ec97f0f868c9972c163b0c95f7", + "shasum": "" + }, + "require": { + "ext-json": "*", + "ext-openssl": "*", + "ext-sodium": "*", + "guzzlehttp/guzzle": "^6.5|^7.0", + "kevinrob/guzzle-cache-middleware": "^3.3", + "php": "^7.2.0|^8.0", + "psr/cache": "^1.0|^2.0|^3.0", + "psr/log": "^1.1|^2.0|^3.0", + "sop/asn1": "^4.1", + "sop/crypto-bridge": "^0.3.1", + "sop/crypto-encoding": "^0.3.0", + "sop/crypto-types": "^0.3.0", + "sop/x501": "^0.6.1", + "sop/x509": "^0.7.0", + "symfony/cache": "^4.4|^5.2|^6.0" + }, + "require-dev": { + "phpseclib/phpseclib": "^3.0.1", + "phpstan/phpstan": "^0.12.64", + "phpunit/phpunit": "^8.5.29", + "sebastian/comparator": "^3.0.5", + "symfony/console": "^5.2", + "symfony/dotenv": "^5.2", + "symfony/var-dumper": "^5.2" + }, + "type": "library", + "autoload": { + "psr-4": { + "MadWizard\\WebAuthn\\": "src/" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Thomas Bleeker", + "email": "support@madwizard.org" + } + ], + "description": "Web Authentication API server for PHP", + "support": { + "issues": "https://github.com/madwizard-org/webauthn-server/issues", + "source": "https://github.com/madwizard-org/webauthn-server/tree/v0.9.1" + }, + "time": "2023-01-03T14:00:10+00:00" + }, + { + "name": "psr/cache", + "version": "1.0.1", + "source": { + "type": "git", + "url": "https://github.com/php-fig/cache.git", + "reference": "d11b50ad223250cf17b86e38383413f5a6764bf8" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/php-fig/cache/zipball/d11b50ad223250cf17b86e38383413f5a6764bf8", + "reference": "d11b50ad223250cf17b86e38383413f5a6764bf8", + "shasum": "" + }, + "require": { + "php": ">=5.3.0" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-master": "1.0.x-dev" + } + }, + "autoload": { + "psr-4": { + "Psr\\Cache\\": "src/" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "PHP-FIG", + "homepage": "http://www.php-fig.org/" + } + ], + "description": "Common interface for caching libraries", + "keywords": [ + "cache", + "psr", + "psr-6" + ], + "support": { + "source": "https://github.com/php-fig/cache/tree/master" + }, + "time": "2016-08-06T20:24:11+00:00" + }, + { + "name": "psr/container", + "version": "1.1.2", + "source": { + "type": "git", + "url": "https://github.com/php-fig/container.git", + "reference": "513e0666f7216c7459170d56df27dfcefe1689ea" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/php-fig/container/zipball/513e0666f7216c7459170d56df27dfcefe1689ea", + "reference": "513e0666f7216c7459170d56df27dfcefe1689ea", + "shasum": "" + }, + "require": { + "php": ">=7.4.0" + }, + "type": "library", + "autoload": { + "psr-4": { + "Psr\\Container\\": "src/" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "PHP-FIG", + "homepage": "https://www.php-fig.org/" + } + ], + "description": "Common Container Interface (PHP FIG PSR-11)", + "homepage": "https://github.com/php-fig/container", + "keywords": [ + "PSR-11", + "container", + "container-interface", + "container-interop", + "psr" + ], + "support": { + "issues": "https://github.com/php-fig/container/issues", + "source": "https://github.com/php-fig/container/tree/1.1.2" + }, + "time": "2021-11-05T16:50:12+00:00" + }, + { + "name": "psr/http-client", + "version": "1.0.2", + "source": { + "type": "git", + "url": "https://github.com/php-fig/http-client.git", + "reference": "0955afe48220520692d2d09f7ab7e0f93ffd6a31" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/php-fig/http-client/zipball/0955afe48220520692d2d09f7ab7e0f93ffd6a31", + "reference": "0955afe48220520692d2d09f7ab7e0f93ffd6a31", + "shasum": "" + }, + "require": { + "php": "^7.0 || ^8.0", + "psr/http-message": "^1.0 || ^2.0" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-master": "1.0.x-dev" + } + }, + "autoload": { + "psr-4": { + "Psr\\Http\\Client\\": "src/" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "PHP-FIG", + "homepage": "https://www.php-fig.org/" + } + ], + "description": "Common interface for HTTP clients", + "homepage": "https://github.com/php-fig/http-client", + "keywords": [ + "http", + "http-client", + "psr", + "psr-18" + ], + "support": { + "source": "https://github.com/php-fig/http-client/tree/1.0.2" + }, + "time": "2023-04-10T20:12:12+00:00" + }, + { + "name": "psr/http-factory", + "version": "1.0.2", + "source": { + "type": "git", + "url": "https://github.com/php-fig/http-factory.git", + "reference": "e616d01114759c4c489f93b099585439f795fe35" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/php-fig/http-factory/zipball/e616d01114759c4c489f93b099585439f795fe35", + "reference": "e616d01114759c4c489f93b099585439f795fe35", + "shasum": "" + }, + "require": { + "php": ">=7.0.0", + "psr/http-message": "^1.0 || ^2.0" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-master": "1.0.x-dev" + } + }, + "autoload": { + "psr-4": { + "Psr\\Http\\Message\\": "src/" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "PHP-FIG", + "homepage": "https://www.php-fig.org/" + } + ], + "description": "Common interfaces for PSR-7 HTTP message factories", + "keywords": [ + "factory", + "http", + "message", + "psr", + "psr-17", + "psr-7", + "request", + "response" + ], + "support": { + "source": "https://github.com/php-fig/http-factory/tree/1.0.2" + }, + "time": "2023-04-10T20:10:41+00:00" + }, + { + "name": "psr/http-message", + "version": "2.0", + "source": { + "type": "git", + "url": "https://github.com/php-fig/http-message.git", + "reference": "402d35bcb92c70c026d1a6a9883f06b2ead23d71" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/php-fig/http-message/zipball/402d35bcb92c70c026d1a6a9883f06b2ead23d71", + "reference": "402d35bcb92c70c026d1a6a9883f06b2ead23d71", + "shasum": "" + }, + "require": { + "php": "^7.2 || ^8.0" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-master": "2.0.x-dev" + } + }, + "autoload": { + "psr-4": { + "Psr\\Http\\Message\\": "src/" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "PHP-FIG", + "homepage": "https://www.php-fig.org/" + } + ], + "description": "Common interface for HTTP messages", + "homepage": "https://github.com/php-fig/http-message", + "keywords": [ + "http", + "http-message", + "psr", + "psr-7", + "request", + "response" + ], + "support": { + "source": "https://github.com/php-fig/http-message/tree/2.0" + }, + "time": "2023-04-04T09:54:51+00:00" + }, + { + "name": "psr/log", + "version": "1.1.4", + "source": { + "type": "git", + "url": "https://github.com/php-fig/log.git", + "reference": "d49695b909c3b7628b6289db5479a1c204601f11" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/php-fig/log/zipball/d49695b909c3b7628b6289db5479a1c204601f11", + "reference": "d49695b909c3b7628b6289db5479a1c204601f11", + "shasum": "" + }, + "require": { + "php": ">=5.3.0" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-master": "1.1.x-dev" + } + }, + "autoload": { + "psr-4": { + "Psr\\Log\\": "Psr/Log/" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "PHP-FIG", + "homepage": "https://www.php-fig.org/" + } + ], + "description": "Common interface for logging libraries", + "homepage": "https://github.com/php-fig/log", + "keywords": [ + "log", + "psr", + "psr-3" + ], + "support": { + "source": "https://github.com/php-fig/log/tree/1.1.4" + }, + "time": "2021-05-03T11:20:27+00:00" + }, + { + "name": "ralouphie/getallheaders", + "version": "3.0.3", + "source": { + "type": "git", + "url": "https://github.com/ralouphie/getallheaders.git", + "reference": "120b605dfeb996808c31b6477290a714d356e822" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/ralouphie/getallheaders/zipball/120b605dfeb996808c31b6477290a714d356e822", + "reference": "120b605dfeb996808c31b6477290a714d356e822", + "shasum": "" + }, + "require": { + "php": ">=5.6" + }, + "require-dev": { + "php-coveralls/php-coveralls": "^2.1", + "phpunit/phpunit": "^5 || ^6.5" + }, + "type": "library", + "autoload": { + "files": [ + "src/getallheaders.php" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Ralph Khattar", + "email": "ralph.khattar@gmail.com" + } + ], + "description": "A polyfill for getallheaders.", + "support": { + "issues": "https://github.com/ralouphie/getallheaders/issues", + "source": "https://github.com/ralouphie/getallheaders/tree/develop" + }, + "time": "2019-03-08T08:55:37+00:00" + }, + { + "name": "sop/asn1", + "version": "4.1.1", + "source": { + "type": "git", + "url": "https://github.com/sop/asn1.git", + "reference": "074efe8f7c353c54edc809ac3d74efda83edd776" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/sop/asn1/zipball/074efe8f7c353c54edc809ac3d74efda83edd776", + "reference": "074efe8f7c353c54edc809ac3d74efda83edd776", + "shasum": "" + }, + "require": { + "ext-gmp": "*", + "ext-mbstring": "*", + "php": ">=7.2" + }, + "require-dev": { + "phpunit/phpunit": "^8.1" + }, + "type": "library", + "autoload": { + "psr-4": { + "Sop\\ASN1\\": "lib/ASN1/" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Joni Eskelinen", + "email": "jonieske@gmail.com", + "role": "Developer" + } + ], + "description": "A PHP library for X.690 ASN.1 DER encoding and decoding.", + "homepage": "https://github.com/sop/asn1", + "keywords": [ + "DER", + "asn.1", + "asn1", + "x.690", + "x690" + ], + "support": { + "issues": "https://github.com/sop/asn1/issues", + "source": "https://github.com/sop/asn1/tree/4.1.1" + }, + "time": "2022-02-17T07:08:51+00:00" + }, + { + "name": "sop/crypto-bridge", + "version": "0.3.1", + "source": { + "type": "git", + "url": "https://github.com/sop/crypto-bridge.git", + "reference": "f3fe51654329e16a81c333212dd190c0a46f9ae2" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/sop/crypto-bridge/zipball/f3fe51654329e16a81c333212dd190c0a46f9ae2", + "reference": "f3fe51654329e16a81c333212dd190c0a46f9ae2", + "shasum": "" + }, + "require": { + "ext-openssl": "*", + "php": ">=7.2", + "sop/crypto-types": "^0.3.0" + }, + "require-dev": { + "phpunit/phpunit": "^8.1" + }, + "type": "library", + "autoload": { + "psr-4": { + "Sop\\CryptoBridge\\": "lib/CryptoBridge/" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Joni Eskelinen", + "email": "jonieske@gmail.com", + "role": "Developer" + } + ], + "description": "A PHP library providing cryptography support for various PKCS applications.", + "homepage": "https://github.com/sop/crypto-bridge", + "keywords": [ + "cryptography", + "decrypt", + "encrypt", + "pkcs", + "sign", + "verify" + ], + "support": { + "issues": "https://github.com/sop/crypto-bridge/issues", + "source": "https://github.com/sop/crypto-bridge/tree/0.3.1" + }, + "time": "2019-05-23T07:09:02+00:00" + }, + { + "name": "sop/crypto-encoding", + "version": "0.3.0", + "source": { + "type": "git", + "url": "https://github.com/sop/crypto-encoding.git", + "reference": "07537353e1b0e76c382a58a9c6e041b7c7dc9052" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/sop/crypto-encoding/zipball/07537353e1b0e76c382a58a9c6e041b7c7dc9052", + "reference": "07537353e1b0e76c382a58a9c6e041b7c7dc9052", + "shasum": "" + }, + "require": { + "php": ">=7.2" + }, + "require-dev": { + "phpunit/phpunit": "^8.1" + }, + "type": "library", + "autoload": { + "psr-4": { + "Sop\\CryptoEncoding\\": "lib/CryptoEncoding/" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Joni Eskelinen", + "email": "jonieske@gmail.com", + "role": "Developer" + } + ], + "description": "A PHP implementation of textual encodings of cryptographic structures.", + "homepage": "https://github.com/sop/crypto-encoding", + "keywords": [ + "Private Key", + "certificate", + "pem", + "public key" + ], + "support": { + "issues": "https://github.com/sop/crypto-encoding/issues", + "source": "https://github.com/sop/crypto-encoding/tree/master" + }, + "time": "2019-05-22T11:46:12+00:00" + }, + { + "name": "sop/crypto-types", + "version": "0.3.0", + "source": { + "type": "git", + "url": "https://github.com/sop/crypto-types.git", + "reference": "9da5b0a6e9e308ae0f75745dc41b6d24bd4136f8" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/sop/crypto-types/zipball/9da5b0a6e9e308ae0f75745dc41b6d24bd4136f8", + "reference": "9da5b0a6e9e308ae0f75745dc41b6d24bd4136f8", + "shasum": "" + }, + "require": { + "ext-gmp": "*", + "php": ">=7.2", + "sop/asn1": "^4.0.0", + "sop/crypto-encoding": "^0.3.0" + }, + "require-dev": { + "phpunit/phpunit": "^8.1" + }, + "type": "library", + "autoload": { + "psr-4": { + "Sop\\CryptoTypes\\": "lib/CryptoTypes/" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Joni Eskelinen", + "email": "jonieske@gmail.com", + "role": "Developer" + } + ], + "description": "A PHP library of various ASN.1 types for cryptographic applications.", + "homepage": "https://github.com/sop/crypto-types", + "keywords": [ + "Private Key", + "algorithm identifier", + "asn1", + "cryptography", + "ec", + "public key", + "rsa", + "signature" + ], + "support": { + "issues": "https://github.com/sop/crypto-types/issues", + "source": "https://github.com/sop/crypto-types/tree/master" + }, + "time": "2019-05-22T12:33:51+00:00" + }, + { + "name": "sop/x501", + "version": "0.6.1", + "source": { + "type": "git", + "url": "https://github.com/sop/x501.git", + "reference": "7978bfac25ff73c4c20bed0794d4d88cca8f6895" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/sop/x501/zipball/7978bfac25ff73c4c20bed0794d4d88cca8f6895", + "reference": "7978bfac25ff73c4c20bed0794d4d88cca8f6895", + "shasum": "" + }, + "require": { + "ext-intl": "*", + "ext-mbstring": "*", + "php": ">=7.2", + "sop/asn1": "^4.0.0" + }, + "require-dev": { + "phpunit/phpunit": "^8.1" + }, + "type": "library", + "autoload": { + "psr-4": { + "Sop\\X501\\": "lib/X501/" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Joni Eskelinen", + "email": "jonieske@gmail.com", + "role": "Developer" + } + ], + "description": "A PHP library for X.501 ASN.1 types, X.520 attributes and DN parsing.", + "homepage": "https://github.com/sop/x501", + "keywords": [ + "attribute", + "dn", + "ldap", + "rdn", + "x.501", + "x.520", + "x501", + "x520" + ], + "support": { + "issues": "https://github.com/sop/x501/issues", + "source": "https://github.com/sop/x501/tree/0.6.1" + }, + "time": "2019-06-27T10:30:28+00:00" + }, + { + "name": "sop/x509", + "version": "0.7.1", + "source": { + "type": "git", + "url": "https://github.com/sop/x509.git", + "reference": "4d461e0d07a4de2cbafef090cc44de3ef6077b50" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/sop/x509/zipball/4d461e0d07a4de2cbafef090cc44de3ef6077b50", + "reference": "4d461e0d07a4de2cbafef090cc44de3ef6077b50", + "shasum": "" + }, + "require": { + "ext-gmp": "*", + "php": ">=7.2", + "sop/asn1": "^4.0.0", + "sop/crypto-bridge": "^0.3.1", + "sop/crypto-encoding": "^0.3.0", + "sop/crypto-types": "^0.3.0", + "sop/x501": "^0.6.1" + }, + "require-dev": { + "phpunit/phpunit": "^8.1" + }, + "type": "library", + "autoload": { + "psr-4": { + "Sop\\X509\\": "lib/X509/" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Joni Eskelinen", + "email": "jonieske@gmail.com", + "role": "Developer" + } + ], + "description": "A PHP library for X.509 public key certificates, attribute certificates, certification requests and certification path validation.", + "homepage": "https://github.com/sop/x509", + "keywords": [ + "ac", + "attribute certificate", + "certificate", + "certification request", + "csr", + "x.509", + "x509" + ], + "support": { + "issues": "https://github.com/sop/x509/issues", + "source": "https://github.com/sop/x509/tree/0.7.1" + }, + "time": "2021-01-01T14:55:03+00:00" + }, + { + "name": "symfony/cache", + "version": "v5.4.23", + "source": { + "type": "git", + "url": "https://github.com/symfony/cache.git", + "reference": "983c79ff28612cdfd66d8e44e1a06e5afc87e107" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/symfony/cache/zipball/983c79ff28612cdfd66d8e44e1a06e5afc87e107", + "reference": "983c79ff28612cdfd66d8e44e1a06e5afc87e107", + "shasum": "" + }, + "require": { + "php": ">=7.2.5", + "psr/cache": "^1.0|^2.0", + "psr/log": "^1.1|^2|^3", + "symfony/cache-contracts": "^1.1.7|^2", + "symfony/deprecation-contracts": "^2.1|^3", + "symfony/polyfill-php73": "^1.9", + "symfony/polyfill-php80": "^1.16", + "symfony/service-contracts": "^1.1|^2|^3", + "symfony/var-exporter": "^4.4|^5.0|^6.0" + }, + "conflict": { + "doctrine/dbal": "<2.13.1", + "symfony/dependency-injection": "<4.4", + "symfony/http-kernel": "<4.4", + "symfony/var-dumper": "<4.4" + }, + "provide": { + "psr/cache-implementation": "1.0|2.0", + "psr/simple-cache-implementation": "1.0|2.0", + "symfony/cache-implementation": "1.0|2.0" + }, + "require-dev": { + "cache/integration-tests": "dev-master", + "doctrine/cache": "^1.6|^2.0", + "doctrine/dbal": "^2.13.1|^3.0", + "predis/predis": "^1.1", + "psr/simple-cache": "^1.0|^2.0", + "symfony/config": "^4.4|^5.0|^6.0", + "symfony/dependency-injection": "^4.4|^5.0|^6.0", + "symfony/filesystem": "^4.4|^5.0|^6.0", + "symfony/http-kernel": "^4.4|^5.0|^6.0", + "symfony/messenger": "^4.4|^5.0|^6.0", + "symfony/var-dumper": "^4.4|^5.0|^6.0" + }, + "type": "library", + "autoload": { + "psr-4": { + "Symfony\\Component\\Cache\\": "" + }, + "exclude-from-classmap": [ + "/Tests/" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Nicolas Grekas", + "email": "p@tchwork.com" + }, + { + "name": "Symfony Community", + "homepage": "https://symfony.com/contributors" + } + ], + "description": "Provides extended PSR-6, PSR-16 (and tags) implementations", + "homepage": "https://symfony.com", + "keywords": [ + "caching", + "psr6" + ], + "support": { + "source": "https://github.com/symfony/cache/tree/v5.4.23" + }, + "funding": [ + { + "url": "https://symfony.com/sponsor", + "type": "custom" + }, + { + "url": "https://github.com/fabpot", + "type": "github" + }, + { + "url": "https://tidelift.com/funding/github/packagist/symfony/symfony", + "type": "tidelift" + } + ], + "time": "2023-04-21T15:38:51+00:00" + }, + { + "name": "symfony/cache-contracts", + "version": "v2.5.2", + "source": { + "type": "git", + "url": "https://github.com/symfony/cache-contracts.git", + "reference": "64be4a7acb83b6f2bf6de9a02cee6dad41277ebc" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/symfony/cache-contracts/zipball/64be4a7acb83b6f2bf6de9a02cee6dad41277ebc", + "reference": "64be4a7acb83b6f2bf6de9a02cee6dad41277ebc", + "shasum": "" + }, + "require": { + "php": ">=7.2.5", + "psr/cache": "^1.0|^2.0|^3.0" + }, + "suggest": { + "symfony/cache-implementation": "" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-main": "2.5-dev" + }, + "thanks": { + "name": "symfony/contracts", + "url": "https://github.com/symfony/contracts" + } + }, + "autoload": { + "psr-4": { + "Symfony\\Contracts\\Cache\\": "" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Nicolas Grekas", + "email": "p@tchwork.com" + }, + { + "name": "Symfony Community", + "homepage": "https://symfony.com/contributors" + } + ], + "description": "Generic abstractions related to caching", + "homepage": "https://symfony.com", + "keywords": [ + "abstractions", + "contracts", + "decoupling", + "interfaces", + "interoperability", + "standards" + ], + "support": { + "source": "https://github.com/symfony/cache-contracts/tree/v2.5.2" + }, + "funding": [ + { + "url": "https://symfony.com/sponsor", + "type": "custom" + }, + { + "url": "https://github.com/fabpot", + "type": "github" + }, + { + "url": "https://tidelift.com/funding/github/packagist/symfony/symfony", + "type": "tidelift" + } + ], + "time": "2022-01-02T09:53:40+00:00" + }, + { + "name": "symfony/deprecation-contracts", + "version": "v2.5.2", + "source": { + "type": "git", + "url": "https://github.com/symfony/deprecation-contracts.git", + "reference": "e8b495ea28c1d97b5e0c121748d6f9b53d075c66" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/symfony/deprecation-contracts/zipball/e8b495ea28c1d97b5e0c121748d6f9b53d075c66", + "reference": "e8b495ea28c1d97b5e0c121748d6f9b53d075c66", + "shasum": "" + }, + "require": { + "php": ">=7.1" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-main": "2.5-dev" + }, + "thanks": { + "name": "symfony/contracts", + "url": "https://github.com/symfony/contracts" + } + }, + "autoload": { + "files": [ + "function.php" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Nicolas Grekas", + "email": "p@tchwork.com" + }, + { + "name": "Symfony Community", + "homepage": "https://symfony.com/contributors" + } + ], + "description": "A generic function and convention to trigger deprecation notices", + "homepage": "https://symfony.com", + "support": { + "source": "https://github.com/symfony/deprecation-contracts/tree/v2.5.2" + }, + "funding": [ + { + "url": "https://symfony.com/sponsor", + "type": "custom" + }, + { + "url": "https://github.com/fabpot", + "type": "github" + }, + { + "url": "https://tidelift.com/funding/github/packagist/symfony/symfony", + "type": "tidelift" + } + ], + "time": "2022-01-02T09:53:40+00:00" + }, + { + "name": "symfony/polyfill-php73", + "version": "v1.27.0", + "source": { + "type": "git", + "url": "https://github.com/symfony/polyfill-php73.git", + "reference": "9e8ecb5f92152187c4799efd3c96b78ccab18ff9" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/symfony/polyfill-php73/zipball/9e8ecb5f92152187c4799efd3c96b78ccab18ff9", + "reference": "9e8ecb5f92152187c4799efd3c96b78ccab18ff9", + "shasum": "" + }, + "require": { + "php": ">=7.1" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-main": "1.27-dev" + }, + "thanks": { + "name": "symfony/polyfill", + "url": "https://github.com/symfony/polyfill" + } + }, + "autoload": { + "files": [ + "bootstrap.php" + ], + "psr-4": { + "Symfony\\Polyfill\\Php73\\": "" + }, + "classmap": [ + "Resources/stubs" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Nicolas Grekas", + "email": "p@tchwork.com" + }, + { + "name": "Symfony Community", + "homepage": "https://symfony.com/contributors" + } + ], + "description": "Symfony polyfill backporting some PHP 7.3+ features to lower PHP versions", + "homepage": "https://symfony.com", + "keywords": [ + "compatibility", + "polyfill", + "portable", + "shim" + ], + "support": { + "source": "https://github.com/symfony/polyfill-php73/tree/v1.27.0" + }, + "funding": [ + { + "url": "https://symfony.com/sponsor", + "type": "custom" + }, + { + "url": "https://github.com/fabpot", + "type": "github" + }, + { + "url": "https://tidelift.com/funding/github/packagist/symfony/symfony", + "type": "tidelift" + } + ], + "time": "2022-11-03T14:55:06+00:00" + }, + { + "name": "symfony/polyfill-php80", + "version": "v1.27.0", + "source": { + "type": "git", + "url": "https://github.com/symfony/polyfill-php80.git", + "reference": "7a6ff3f1959bb01aefccb463a0f2cd3d3d2fd936" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/symfony/polyfill-php80/zipball/7a6ff3f1959bb01aefccb463a0f2cd3d3d2fd936", + "reference": "7a6ff3f1959bb01aefccb463a0f2cd3d3d2fd936", + "shasum": "" + }, + "require": { + "php": ">=7.1" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-main": "1.27-dev" + }, + "thanks": { + "name": "symfony/polyfill", + "url": "https://github.com/symfony/polyfill" + } + }, + "autoload": { + "files": [ + "bootstrap.php" + ], + "psr-4": { + "Symfony\\Polyfill\\Php80\\": "" + }, + "classmap": [ + "Resources/stubs" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Ion Bazan", + "email": "ion.bazan@gmail.com" + }, + { + "name": "Nicolas Grekas", + "email": "p@tchwork.com" + }, + { + "name": "Symfony Community", + "homepage": "https://symfony.com/contributors" + } + ], + "description": "Symfony polyfill backporting some PHP 8.0+ features to lower PHP versions", + "homepage": "https://symfony.com", + "keywords": [ + "compatibility", + "polyfill", + "portable", + "shim" + ], + "support": { + "source": "https://github.com/symfony/polyfill-php80/tree/v1.27.0" + }, + "funding": [ + { + "url": "https://symfony.com/sponsor", + "type": "custom" + }, + { + "url": "https://github.com/fabpot", + "type": "github" + }, + { + "url": "https://tidelift.com/funding/github/packagist/symfony/symfony", + "type": "tidelift" + } + ], + "time": "2022-11-03T14:55:06+00:00" + }, + { + "name": "symfony/service-contracts", + "version": "v2.5.2", + "source": { + "type": "git", + "url": "https://github.com/symfony/service-contracts.git", + "reference": "4b426aac47d6427cc1a1d0f7e2ac724627f5966c" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/symfony/service-contracts/zipball/4b426aac47d6427cc1a1d0f7e2ac724627f5966c", + "reference": "4b426aac47d6427cc1a1d0f7e2ac724627f5966c", + "shasum": "" + }, + "require": { + "php": ">=7.2.5", + "psr/container": "^1.1", + "symfony/deprecation-contracts": "^2.1|^3" + }, + "conflict": { + "ext-psr": "<1.1|>=2" + }, + "suggest": { + "symfony/service-implementation": "" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-main": "2.5-dev" + }, + "thanks": { + "name": "symfony/contracts", + "url": "https://github.com/symfony/contracts" + } + }, + "autoload": { + "psr-4": { + "Symfony\\Contracts\\Service\\": "" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Nicolas Grekas", + "email": "p@tchwork.com" + }, + { + "name": "Symfony Community", + "homepage": "https://symfony.com/contributors" + } + ], + "description": "Generic abstractions related to writing services", + "homepage": "https://symfony.com", + "keywords": [ + "abstractions", + "contracts", + "decoupling", + "interfaces", + "interoperability", + "standards" + ], + "support": { + "source": "https://github.com/symfony/service-contracts/tree/v2.5.2" + }, + "funding": [ + { + "url": "https://symfony.com/sponsor", + "type": "custom" + }, + { + "url": "https://github.com/fabpot", + "type": "github" + }, + { + "url": "https://tidelift.com/funding/github/packagist/symfony/symfony", + "type": "tidelift" + } + ], + "time": "2022-05-30T19:17:29+00:00" + }, + { + "name": "symfony/var-exporter", + "version": "v5.4.21", + "source": { + "type": "git", + "url": "https://github.com/symfony/var-exporter.git", + "reference": "be74908a6942fdd331554b3cec27ff41b45ccad4" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/symfony/var-exporter/zipball/be74908a6942fdd331554b3cec27ff41b45ccad4", + "reference": "be74908a6942fdd331554b3cec27ff41b45ccad4", + "shasum": "" + }, + "require": { + "php": ">=7.2.5", + "symfony/polyfill-php80": "^1.16" + }, + "require-dev": { + "symfony/var-dumper": "^4.4.9|^5.0.9|^6.0" + }, + "type": "library", + "autoload": { + "psr-4": { + "Symfony\\Component\\VarExporter\\": "" + }, + "exclude-from-classmap": [ + "/Tests/" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Nicolas Grekas", + "email": "p@tchwork.com" + }, + { + "name": "Symfony Community", + "homepage": "https://symfony.com/contributors" + } + ], + "description": "Allows exporting any serializable PHP data structure to plain PHP code", + "homepage": "https://symfony.com", + "keywords": [ + "clone", + "construct", + "export", + "hydrate", + "instantiate", + "serialize" + ], + "support": { + "source": "https://github.com/symfony/var-exporter/tree/v5.4.21" + }, + "funding": [ + { + "url": "https://symfony.com/sponsor", + "type": "custom" + }, + { + "url": "https://github.com/fabpot", + "type": "github" + }, + { + "url": "https://tidelift.com/funding/github/packagist/symfony/symfony", + "type": "tidelift" + } + ], + "time": "2023-02-21T19:46:44+00:00" + } + ], "packages-dev": [ { "name": "clue/stdio-react", @@ -296,30 +2019,30 @@ }, { "name": "doctrine/instantiator", - "version": "1.4.1", + "version": "1.5.0", "source": { "type": "git", "url": "https://github.com/doctrine/instantiator.git", - "reference": "10dcfce151b967d20fde1b34ae6640712c3891bc" + "reference": "0a0fa9780f5d4e507415a065172d26a98d02047b" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/doctrine/instantiator/zipball/10dcfce151b967d20fde1b34ae6640712c3891bc", - "reference": "10dcfce151b967d20fde1b34ae6640712c3891bc", + "url": "https://api.github.com/repos/doctrine/instantiator/zipball/0a0fa9780f5d4e507415a065172d26a98d02047b", + "reference": "0a0fa9780f5d4e507415a065172d26a98d02047b", "shasum": "" }, "require": { "php": "^7.1 || ^8.0" }, "require-dev": { - "doctrine/coding-standard": "^9", + "doctrine/coding-standard": "^9 || ^11", "ext-pdo": "*", "ext-phar": "*", "phpbench/phpbench": "^0.16 || ^1", "phpstan/phpstan": "^1.4", "phpstan/phpstan-phpunit": "^1", "phpunit/phpunit": "^7.5 || ^8.5 || ^9.5", - "vimeo/psalm": "^4.22" + "vimeo/psalm": "^4.30 || ^5.4" }, "type": "library", "autoload": { @@ -346,7 +2069,7 @@ ], "support": { "issues": "https://github.com/doctrine/instantiator/issues", - "source": "https://github.com/doctrine/instantiator/tree/1.4.1" + "source": "https://github.com/doctrine/instantiator/tree/1.5.0" }, "funding": [ { @@ -362,7 +2085,7 @@ "type": "tidelift" } ], - "time": "2022-03-03T08:28:38+00:00" + "time": "2022-12-30T00:15:36+00:00" }, { "name": "evenement/evenement", @@ -475,16 +2198,16 @@ }, { "name": "myclabs/deep-copy", - "version": "1.11.0", + "version": "1.11.1", "source": { "type": "git", "url": "https://github.com/myclabs/DeepCopy.git", - "reference": "14daed4296fae74d9e3201d2c4925d1acb7aa614" + "reference": "7284c22080590fb39f2ffa3e9057f10a4ddd0e0c" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/myclabs/DeepCopy/zipball/14daed4296fae74d9e3201d2c4925d1acb7aa614", - "reference": "14daed4296fae74d9e3201d2c4925d1acb7aa614", + "url": "https://api.github.com/repos/myclabs/DeepCopy/zipball/7284c22080590fb39f2ffa3e9057f10a4ddd0e0c", + "reference": "7284c22080590fb39f2ffa3e9057f10a4ddd0e0c", "shasum": "" }, "require": { @@ -522,7 +2245,7 @@ ], "support": { "issues": "https://github.com/myclabs/DeepCopy/issues", - "source": "https://github.com/myclabs/DeepCopy/tree/1.11.0" + "source": "https://github.com/myclabs/DeepCopy/tree/1.11.1" }, "funding": [ { @@ -530,20 +2253,20 @@ "type": "tidelift" } ], - "time": "2022-03-03T13:19:32+00:00" + "time": "2023-03-08T13:26:56+00:00" }, { "name": "nikic/php-parser", - "version": "v4.15.2", + "version": "v4.15.4", "source": { "type": "git", "url": "https://github.com/nikic/PHP-Parser.git", - "reference": "f59bbe44bf7d96f24f3e2b4ddc21cd52c1d2adbc" + "reference": "6bb5176bc4af8bcb7d926f88718db9b96a2d4290" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/nikic/PHP-Parser/zipball/f59bbe44bf7d96f24f3e2b4ddc21cd52c1d2adbc", - "reference": "f59bbe44bf7d96f24f3e2b4ddc21cd52c1d2adbc", + "url": "https://api.github.com/repos/nikic/PHP-Parser/zipball/6bb5176bc4af8bcb7d926f88718db9b96a2d4290", + "reference": "6bb5176bc4af8bcb7d926f88718db9b96a2d4290", "shasum": "" }, "require": { @@ -584,9 +2307,9 @@ ], "support": { "issues": "https://github.com/nikic/PHP-Parser/issues", - "source": "https://github.com/nikic/PHP-Parser/tree/v4.15.2" + "source": "https://github.com/nikic/PHP-Parser/tree/v4.15.4" }, - "time": "2022-11-12T15:38:23+00:00" + "time": "2023-03-05T19:49:14+00:00" }, { "name": "phar-io/manifest", @@ -701,23 +2424,23 @@ }, { "name": "phpunit/php-code-coverage", - "version": "9.2.18", + "version": "9.2.26", "source": { "type": "git", "url": "https://github.com/sebastianbergmann/php-code-coverage.git", - "reference": "12fddc491826940cf9b7e88ad9664cf51f0f6d0a" + "reference": "443bc6912c9bd5b409254a40f4b0f4ced7c80ea1" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/sebastianbergmann/php-code-coverage/zipball/12fddc491826940cf9b7e88ad9664cf51f0f6d0a", - "reference": "12fddc491826940cf9b7e88ad9664cf51f0f6d0a", + "url": "https://api.github.com/repos/sebastianbergmann/php-code-coverage/zipball/443bc6912c9bd5b409254a40f4b0f4ced7c80ea1", + "reference": "443bc6912c9bd5b409254a40f4b0f4ced7c80ea1", "shasum": "" }, "require": { "ext-dom": "*", "ext-libxml": "*", "ext-xmlwriter": "*", - "nikic/php-parser": "^4.14", + "nikic/php-parser": "^4.15", "php": ">=7.3", "phpunit/php-file-iterator": "^3.0.3", "phpunit/php-text-template": "^2.0.2", @@ -732,8 +2455,8 @@ "phpunit/phpunit": "^9.3" }, "suggest": { - "ext-pcov": "*", - "ext-xdebug": "*" + "ext-pcov": "PHP extension that provides line coverage", + "ext-xdebug": "PHP extension that provides line coverage as well as branch and path coverage" }, "type": "library", "extra": { @@ -766,7 +2489,7 @@ ], "support": { "issues": "https://github.com/sebastianbergmann/php-code-coverage/issues", - "source": "https://github.com/sebastianbergmann/php-code-coverage/tree/9.2.18" + "source": "https://github.com/sebastianbergmann/php-code-coverage/tree/9.2.26" }, "funding": [ { @@ -774,7 +2497,7 @@ "type": "github" } ], - "time": "2022-10-27T13:35:33+00:00" + "time": "2023-03-06T12:58:08+00:00" }, { "name": "phpunit/php-file-iterator", @@ -1019,20 +2742,20 @@ }, { "name": "phpunit/phpunit", - "version": "9.5.26", + "version": "9.6.7", "source": { "type": "git", "url": "https://github.com/sebastianbergmann/phpunit.git", - "reference": "851867efcbb6a1b992ec515c71cdcf20d895e9d2" + "reference": "c993f0d3b0489ffc42ee2fe0bd645af1538a63b2" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/sebastianbergmann/phpunit/zipball/851867efcbb6a1b992ec515c71cdcf20d895e9d2", - "reference": "851867efcbb6a1b992ec515c71cdcf20d895e9d2", + "url": "https://api.github.com/repos/sebastianbergmann/phpunit/zipball/c993f0d3b0489ffc42ee2fe0bd645af1538a63b2", + "reference": "c993f0d3b0489ffc42ee2fe0bd645af1538a63b2", "shasum": "" }, "require": { - "doctrine/instantiator": "^1.3.1", + "doctrine/instantiator": "^1.3.1 || ^2", "ext-dom": "*", "ext-json": "*", "ext-libxml": "*", @@ -1061,8 +2784,8 @@ "sebastian/version": "^3.0.2" }, "suggest": { - "ext-soap": "*", - "ext-xdebug": "*" + "ext-soap": "To be able to generate mocks based on WSDL files", + "ext-xdebug": "PHP extension that provides line coverage as well as branch and path coverage" }, "bin": [ "phpunit" @@ -1070,7 +2793,7 @@ "type": "library", "extra": { "branch-alias": { - "dev-master": "9.5-dev" + "dev-master": "9.6-dev" } }, "autoload": { @@ -1101,7 +2824,8 @@ ], "support": { "issues": "https://github.com/sebastianbergmann/phpunit/issues", - "source": "https://github.com/sebastianbergmann/phpunit/tree/9.5.26" + "security": "https://github.com/sebastianbergmann/phpunit/security/policy", + "source": "https://github.com/sebastianbergmann/phpunit/tree/9.6.7" }, "funding": [ { @@ -1117,55 +2841,7 @@ "type": "tidelift" } ], - "time": "2022-10-28T06:00:21+00:00" - }, - { - "name": "psr/container", - "version": "1.1.2", - "source": { - "type": "git", - "url": "https://github.com/php-fig/container.git", - "reference": "513e0666f7216c7459170d56df27dfcefe1689ea" - }, - "dist": { - "type": "zip", - "url": "https://api.github.com/repos/php-fig/container/zipball/513e0666f7216c7459170d56df27dfcefe1689ea", - "reference": "513e0666f7216c7459170d56df27dfcefe1689ea", - "shasum": "" - }, - "require": { - "php": ">=7.4.0" - }, - "type": "library", - "autoload": { - "psr-4": { - "Psr\\Container\\": "src/" - } - }, - "notification-url": "https://packagist.org/downloads/", - "license": [ - "MIT" - ], - "authors": [ - { - "name": "PHP-FIG", - "homepage": "https://www.php-fig.org/" - } - ], - "description": "Common Container Interface (PHP FIG PSR-11)", - "homepage": "https://github.com/php-fig/container", - "keywords": [ - "PSR-11", - "container", - "container-interface", - "container-interop", - "psr" - ], - "support": { - "issues": "https://github.com/php-fig/container/issues", - "source": "https://github.com/php-fig/container/tree/1.1.2" - }, - "time": "2021-11-05T16:50:12+00:00" + "time": "2023-04-14T08:58:40+00:00" }, { "name": "react/event-loop", @@ -1693,16 +3369,16 @@ }, { "name": "sebastian/environment", - "version": "5.1.4", + "version": "5.1.5", "source": { "type": "git", "url": "https://github.com/sebastianbergmann/environment.git", - "reference": "1b5dff7bb151a4db11d49d90e5408e4e938270f7" + "reference": "830c43a844f1f8d5b7a1f6d6076b784454d8b7ed" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/sebastianbergmann/environment/zipball/1b5dff7bb151a4db11d49d90e5408e4e938270f7", - "reference": "1b5dff7bb151a4db11d49d90e5408e4e938270f7", + "url": "https://api.github.com/repos/sebastianbergmann/environment/zipball/830c43a844f1f8d5b7a1f6d6076b784454d8b7ed", + "reference": "830c43a844f1f8d5b7a1f6d6076b784454d8b7ed", "shasum": "" }, "require": { @@ -1744,7 +3420,7 @@ ], "support": { "issues": "https://github.com/sebastianbergmann/environment/issues", - "source": "https://github.com/sebastianbergmann/environment/tree/5.1.4" + "source": "https://github.com/sebastianbergmann/environment/tree/5.1.5" }, "funding": [ { @@ -1752,7 +3428,7 @@ "type": "github" } ], - "time": "2022-04-03T09:37:03+00:00" + "time": "2023-02-03T06:03:51+00:00" }, { "name": "sebastian/exporter", @@ -2066,16 +3742,16 @@ }, { "name": "sebastian/recursion-context", - "version": "4.0.4", + "version": "4.0.5", "source": { "type": "git", "url": "https://github.com/sebastianbergmann/recursion-context.git", - "reference": "cd9d8cf3c5804de4341c283ed787f099f5506172" + "reference": "e75bd0f07204fec2a0af9b0f3cfe97d05f92efc1" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/sebastianbergmann/recursion-context/zipball/cd9d8cf3c5804de4341c283ed787f099f5506172", - "reference": "cd9d8cf3c5804de4341c283ed787f099f5506172", + "url": "https://api.github.com/repos/sebastianbergmann/recursion-context/zipball/e75bd0f07204fec2a0af9b0f3cfe97d05f92efc1", + "reference": "e75bd0f07204fec2a0af9b0f3cfe97d05f92efc1", "shasum": "" }, "require": { @@ -2114,10 +3790,10 @@ } ], "description": "Provides functionality to recursively process PHP variables", - "homepage": "http://www.github.com/sebastianbergmann/recursion-context", + "homepage": "https://github.com/sebastianbergmann/recursion-context", "support": { "issues": "https://github.com/sebastianbergmann/recursion-context/issues", - "source": "https://github.com/sebastianbergmann/recursion-context/tree/4.0.4" + "source": "https://github.com/sebastianbergmann/recursion-context/tree/4.0.5" }, "funding": [ { @@ -2125,7 +3801,7 @@ "type": "github" } ], - "time": "2020-10-26T13:17:30+00:00" + "time": "2023-02-03T06:07:39+00:00" }, { "name": "sebastian/resource-operations", @@ -2184,16 +3860,16 @@ }, { "name": "sebastian/type", - "version": "3.2.0", + "version": "3.2.1", "source": { "type": "git", "url": "https://github.com/sebastianbergmann/type.git", - "reference": "fb3fe09c5f0bae6bc27ef3ce933a1e0ed9464b6e" + "reference": "75e2c2a32f5e0b3aef905b9ed0b179b953b3d7c7" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/sebastianbergmann/type/zipball/fb3fe09c5f0bae6bc27ef3ce933a1e0ed9464b6e", - "reference": "fb3fe09c5f0bae6bc27ef3ce933a1e0ed9464b6e", + "url": "https://api.github.com/repos/sebastianbergmann/type/zipball/75e2c2a32f5e0b3aef905b9ed0b179b953b3d7c7", + "reference": "75e2c2a32f5e0b3aef905b9ed0b179b953b3d7c7", "shasum": "" }, "require": { @@ -2228,7 +3904,7 @@ "homepage": "https://github.com/sebastianbergmann/type", "support": { "issues": "https://github.com/sebastianbergmann/type/issues", - "source": "https://github.com/sebastianbergmann/type/tree/3.2.0" + "source": "https://github.com/sebastianbergmann/type/tree/3.2.1" }, "funding": [ { @@ -2236,7 +3912,7 @@ "type": "github" } ], - "time": "2022-09-12T14:47:03+00:00" + "time": "2023-02-03T06:13:03+00:00" }, { "name": "sebastian/version", @@ -2357,16 +4033,16 @@ }, { "name": "squizlabs/php_codesniffer", - "version": "3.7.1", + "version": "3.7.2", "source": { "type": "git", "url": "https://github.com/squizlabs/PHP_CodeSniffer.git", - "reference": "1359e176e9307e906dc3d890bcc9603ff6d90619" + "reference": "ed8e00df0a83aa96acf703f8c2979ff33341f879" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/squizlabs/PHP_CodeSniffer/zipball/1359e176e9307e906dc3d890bcc9603ff6d90619", - "reference": "1359e176e9307e906dc3d890bcc9603ff6d90619", + "url": "https://api.github.com/repos/squizlabs/PHP_CodeSniffer/zipball/ed8e00df0a83aa96acf703f8c2979ff33341f879", + "reference": "ed8e00df0a83aa96acf703f8c2979ff33341f879", "shasum": "" }, "require": { @@ -2402,27 +4078,28 @@ "homepage": "https://github.com/squizlabs/PHP_CodeSniffer", "keywords": [ "phpcs", - "standards" + "standards", + "static analysis" ], "support": { "issues": "https://github.com/squizlabs/PHP_CodeSniffer/issues", "source": "https://github.com/squizlabs/PHP_CodeSniffer", "wiki": "https://github.com/squizlabs/PHP_CodeSniffer/wiki" }, - "time": "2022-06-18T07:21:10+00:00" + "time": "2023-02-22T23:07:41+00:00" }, { "name": "symfony/console", - "version": "v5.4.15", + "version": "v5.4.23", "source": { "type": "git", "url": "https://github.com/symfony/console.git", - "reference": "ea59bb0edfaf9f28d18d8791410ee0355f317669" + "reference": "90f21e27d0d88ce38720556dd164d4a1e4c3934c" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/console/zipball/ea59bb0edfaf9f28d18d8791410ee0355f317669", - "reference": "ea59bb0edfaf9f28d18d8791410ee0355f317669", + "url": "https://api.github.com/repos/symfony/console/zipball/90f21e27d0d88ce38720556dd164d4a1e4c3934c", + "reference": "90f21e27d0d88ce38720556dd164d4a1e4c3934c", "shasum": "" }, "require": { @@ -2476,90 +4153,23 @@ "authors": [ { "name": "Fabien Potencier", - "email": "fabien@symfony.com" - }, - { - "name": "Symfony Community", - "homepage": "https://symfony.com/contributors" - } - ], - "description": "Eases the creation of beautiful and testable command line interfaces", - "homepage": "https://symfony.com", - "keywords": [ - "cli", - "command line", - "console", - "terminal" - ], - "support": { - "source": "https://github.com/symfony/console/tree/v5.4.15" - }, - "funding": [ - { - "url": "https://symfony.com/sponsor", - "type": "custom" - }, - { - "url": "https://github.com/fabpot", - "type": "github" - }, - { - "url": "https://tidelift.com/funding/github/packagist/symfony/symfony", - "type": "tidelift" - } - ], - "time": "2022-10-26T21:41:52+00:00" - }, - { - "name": "symfony/deprecation-contracts", - "version": "v2.5.2", - "source": { - "type": "git", - "url": "https://github.com/symfony/deprecation-contracts.git", - "reference": "e8b495ea28c1d97b5e0c121748d6f9b53d075c66" - }, - "dist": { - "type": "zip", - "url": "https://api.github.com/repos/symfony/deprecation-contracts/zipball/e8b495ea28c1d97b5e0c121748d6f9b53d075c66", - "reference": "e8b495ea28c1d97b5e0c121748d6f9b53d075c66", - "shasum": "" - }, - "require": { - "php": ">=7.1" - }, - "type": "library", - "extra": { - "branch-alias": { - "dev-main": "2.5-dev" - }, - "thanks": { - "name": "symfony/contracts", - "url": "https://github.com/symfony/contracts" - } - }, - "autoload": { - "files": [ - "function.php" - ] - }, - "notification-url": "https://packagist.org/downloads/", - "license": [ - "MIT" - ], - "authors": [ - { - "name": "Nicolas Grekas", - "email": "p@tchwork.com" + "email": "fabien@symfony.com" }, { "name": "Symfony Community", "homepage": "https://symfony.com/contributors" } ], - "description": "A generic function and convention to trigger deprecation notices", + "description": "Eases the creation of beautiful and testable command line interfaces", "homepage": "https://symfony.com", + "keywords": [ + "cli", + "command-line", + "console", + "terminal" + ], "support": { - "source": "https://github.com/symfony/deprecation-contracts/tree/v2.5.2" + "source": "https://github.com/symfony/console/tree/v5.4.23" }, "funding": [ { @@ -2575,20 +4185,20 @@ "type": "tidelift" } ], - "time": "2022-01-02T09:53:40+00:00" + "time": "2023-04-24T18:47:29+00:00" }, { "name": "symfony/finder", - "version": "v5.4.11", + "version": "v5.4.21", "source": { "type": "git", "url": "https://github.com/symfony/finder.git", - "reference": "7872a66f57caffa2916a584db1aa7f12adc76f8c" + "reference": "078e9a5e1871fcfe6a5ce421b539344c21afef19" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/finder/zipball/7872a66f57caffa2916a584db1aa7f12adc76f8c", - "reference": "7872a66f57caffa2916a584db1aa7f12adc76f8c", + "url": "https://api.github.com/repos/symfony/finder/zipball/078e9a5e1871fcfe6a5ce421b539344c21afef19", + "reference": "078e9a5e1871fcfe6a5ce421b539344c21afef19", "shasum": "" }, "require": { @@ -2622,7 +4232,7 @@ "description": "Finds files and directories via an intuitive fluent interface", "homepage": "https://symfony.com", "support": { - "source": "https://github.com/symfony/finder/tree/v5.4.11" + "source": "https://github.com/symfony/finder/tree/v5.4.21" }, "funding": [ { @@ -2638,7 +4248,7 @@ "type": "tidelift" } ], - "time": "2022-07-29T07:37:50+00:00" + "time": "2023-02-16T09:33:00+00:00" }, { "name": "symfony/polyfill-ctype", @@ -2970,180 +4580,18 @@ ], "time": "2022-11-03T14:55:06+00:00" }, - { - "name": "symfony/polyfill-php73", - "version": "v1.27.0", - "source": { - "type": "git", - "url": "https://github.com/symfony/polyfill-php73.git", - "reference": "9e8ecb5f92152187c4799efd3c96b78ccab18ff9" - }, - "dist": { - "type": "zip", - "url": "https://api.github.com/repos/symfony/polyfill-php73/zipball/9e8ecb5f92152187c4799efd3c96b78ccab18ff9", - "reference": "9e8ecb5f92152187c4799efd3c96b78ccab18ff9", - "shasum": "" - }, - "require": { - "php": ">=7.1" - }, - "type": "library", - "extra": { - "branch-alias": { - "dev-main": "1.27-dev" - }, - "thanks": { - "name": "symfony/polyfill", - "url": "https://github.com/symfony/polyfill" - } - }, - "autoload": { - "files": [ - "bootstrap.php" - ], - "psr-4": { - "Symfony\\Polyfill\\Php73\\": "" - }, - "classmap": [ - "Resources/stubs" - ] - }, - "notification-url": "https://packagist.org/downloads/", - "license": [ - "MIT" - ], - "authors": [ - { - "name": "Nicolas Grekas", - "email": "p@tchwork.com" - }, - { - "name": "Symfony Community", - "homepage": "https://symfony.com/contributors" - } - ], - "description": "Symfony polyfill backporting some PHP 7.3+ features to lower PHP versions", - "homepage": "https://symfony.com", - "keywords": [ - "compatibility", - "polyfill", - "portable", - "shim" - ], - "support": { - "source": "https://github.com/symfony/polyfill-php73/tree/v1.27.0" - }, - "funding": [ - { - "url": "https://symfony.com/sponsor", - "type": "custom" - }, - { - "url": "https://github.com/fabpot", - "type": "github" - }, - { - "url": "https://tidelift.com/funding/github/packagist/symfony/symfony", - "type": "tidelift" - } - ], - "time": "2022-11-03T14:55:06+00:00" - }, - { - "name": "symfony/polyfill-php80", - "version": "v1.27.0", - "source": { - "type": "git", - "url": "https://github.com/symfony/polyfill-php80.git", - "reference": "7a6ff3f1959bb01aefccb463a0f2cd3d3d2fd936" - }, - "dist": { - "type": "zip", - "url": "https://api.github.com/repos/symfony/polyfill-php80/zipball/7a6ff3f1959bb01aefccb463a0f2cd3d3d2fd936", - "reference": "7a6ff3f1959bb01aefccb463a0f2cd3d3d2fd936", - "shasum": "" - }, - "require": { - "php": ">=7.1" - }, - "type": "library", - "extra": { - "branch-alias": { - "dev-main": "1.27-dev" - }, - "thanks": { - "name": "symfony/polyfill", - "url": "https://github.com/symfony/polyfill" - } - }, - "autoload": { - "files": [ - "bootstrap.php" - ], - "psr-4": { - "Symfony\\Polyfill\\Php80\\": "" - }, - "classmap": [ - "Resources/stubs" - ] - }, - "notification-url": "https://packagist.org/downloads/", - "license": [ - "MIT" - ], - "authors": [ - { - "name": "Ion Bazan", - "email": "ion.bazan@gmail.com" - }, - { - "name": "Nicolas Grekas", - "email": "p@tchwork.com" - }, - { - "name": "Symfony Community", - "homepage": "https://symfony.com/contributors" - } - ], - "description": "Symfony polyfill backporting some PHP 8.0+ features to lower PHP versions", - "homepage": "https://symfony.com", - "keywords": [ - "compatibility", - "polyfill", - "portable", - "shim" - ], - "support": { - "source": "https://github.com/symfony/polyfill-php80/tree/v1.27.0" - }, - "funding": [ - { - "url": "https://symfony.com/sponsor", - "type": "custom" - }, - { - "url": "https://github.com/fabpot", - "type": "github" - }, - { - "url": "https://tidelift.com/funding/github/packagist/symfony/symfony", - "type": "tidelift" - } - ], - "time": "2022-11-03T14:55:06+00:00" - }, { "name": "symfony/process", - "version": "v5.4.11", + "version": "v5.4.23", "source": { "type": "git", "url": "https://github.com/symfony/process.git", - "reference": "6e75fe6874cbc7e4773d049616ab450eff537bf1" + "reference": "4b842fc4b61609e0a155a114082bd94e31e98287" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/process/zipball/6e75fe6874cbc7e4773d049616ab450eff537bf1", - "reference": "6e75fe6874cbc7e4773d049616ab450eff537bf1", + "url": "https://api.github.com/repos/symfony/process/zipball/4b842fc4b61609e0a155a114082bd94e31e98287", + "reference": "4b842fc4b61609e0a155a114082bd94e31e98287", "shasum": "" }, "require": { @@ -3176,90 +4624,7 @@ "description": "Executes commands in sub-processes", "homepage": "https://symfony.com", "support": { - "source": "https://github.com/symfony/process/tree/v5.4.11" - }, - "funding": [ - { - "url": "https://symfony.com/sponsor", - "type": "custom" - }, - { - "url": "https://github.com/fabpot", - "type": "github" - }, - { - "url": "https://tidelift.com/funding/github/packagist/symfony/symfony", - "type": "tidelift" - } - ], - "time": "2022-06-27T16:58:25+00:00" - }, - { - "name": "symfony/service-contracts", - "version": "v2.5.2", - "source": { - "type": "git", - "url": "https://github.com/symfony/service-contracts.git", - "reference": "4b426aac47d6427cc1a1d0f7e2ac724627f5966c" - }, - "dist": { - "type": "zip", - "url": "https://api.github.com/repos/symfony/service-contracts/zipball/4b426aac47d6427cc1a1d0f7e2ac724627f5966c", - "reference": "4b426aac47d6427cc1a1d0f7e2ac724627f5966c", - "shasum": "" - }, - "require": { - "php": ">=7.2.5", - "psr/container": "^1.1", - "symfony/deprecation-contracts": "^2.1|^3" - }, - "conflict": { - "ext-psr": "<1.1|>=2" - }, - "suggest": { - "symfony/service-implementation": "" - }, - "type": "library", - "extra": { - "branch-alias": { - "dev-main": "2.5-dev" - }, - "thanks": { - "name": "symfony/contracts", - "url": "https://github.com/symfony/contracts" - } - }, - "autoload": { - "psr-4": { - "Symfony\\Contracts\\Service\\": "" - } - }, - "notification-url": "https://packagist.org/downloads/", - "license": [ - "MIT" - ], - "authors": [ - { - "name": "Nicolas Grekas", - "email": "p@tchwork.com" - }, - { - "name": "Symfony Community", - "homepage": "https://symfony.com/contributors" - } - ], - "description": "Generic abstractions related to writing services", - "homepage": "https://symfony.com", - "keywords": [ - "abstractions", - "contracts", - "decoupling", - "interfaces", - "interoperability", - "standards" - ], - "support": { - "source": "https://github.com/symfony/service-contracts/tree/v2.5.2" + "source": "https://github.com/symfony/process/tree/v5.4.23" }, "funding": [ { @@ -3275,20 +4640,20 @@ "type": "tidelift" } ], - "time": "2022-05-30T19:17:29+00:00" + "time": "2023-04-18T13:50:24+00:00" }, { "name": "symfony/string", - "version": "v5.4.15", + "version": "v5.4.22", "source": { "type": "git", "url": "https://github.com/symfony/string.git", - "reference": "571334ce9f687e3e6af72db4d3b2a9431e4fd9ed" + "reference": "8036a4c76c0dd29e60b6a7cafcacc50cf088ea62" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/string/zipball/571334ce9f687e3e6af72db4d3b2a9431e4fd9ed", - "reference": "571334ce9f687e3e6af72db4d3b2a9431e4fd9ed", + "url": "https://api.github.com/repos/symfony/string/zipball/8036a4c76c0dd29e60b6a7cafcacc50cf088ea62", + "reference": "8036a4c76c0dd29e60b6a7cafcacc50cf088ea62", "shasum": "" }, "require": { @@ -3345,7 +4710,7 @@ "utf8" ], "support": { - "source": "https://github.com/symfony/string/tree/v5.4.15" + "source": "https://github.com/symfony/string/tree/v5.4.22" }, "funding": [ { @@ -3361,20 +4726,20 @@ "type": "tidelift" } ], - "time": "2022-10-05T15:16:54+00:00" + "time": "2023-03-14T06:11:53+00:00" }, { "name": "symfony/yaml", - "version": "v5.4.14", + "version": "v5.4.23", "source": { "type": "git", "url": "https://github.com/symfony/yaml.git", - "reference": "e83fe9a72011f07c662da46a05603d66deeeb487" + "reference": "4cd2e3ea301aadd76a4172756296fe552fb45b0b" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/yaml/zipball/e83fe9a72011f07c662da46a05603d66deeeb487", - "reference": "e83fe9a72011f07c662da46a05603d66deeeb487", + "url": "https://api.github.com/repos/symfony/yaml/zipball/4cd2e3ea301aadd76a4172756296fe552fb45b0b", + "reference": "4cd2e3ea301aadd76a4172756296fe552fb45b0b", "shasum": "" }, "require": { @@ -3420,7 +4785,7 @@ "description": "Loads and dumps YAML files", "homepage": "https://symfony.com", "support": { - "source": "https://github.com/symfony/yaml/tree/v5.4.14" + "source": "https://github.com/symfony/yaml/tree/v5.4.23" }, "funding": [ { @@ -3436,7 +4801,7 @@ "type": "tidelift" } ], - "time": "2022-10-03T15:15:50+00:00" + "time": "2023-04-23T19:33:36+00:00" }, { "name": "theseer/tokenizer", @@ -3541,16 +4906,16 @@ }, { "name": "yoast/phpunit-polyfills", - "version": "1.0.3", + "version": "1.0.5", "source": { "type": "git", "url": "https://github.com/Yoast/PHPUnit-Polyfills.git", - "reference": "5ea3536428944955f969bc764bbe09738e151ada" + "reference": "3b59adeef77fb1c03ff5381dbb9d68b0aaff3171" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/Yoast/PHPUnit-Polyfills/zipball/5ea3536428944955f969bc764bbe09738e151ada", - "reference": "5ea3536428944955f969bc764bbe09738e151ada", + "url": "https://api.github.com/repos/Yoast/PHPUnit-Polyfills/zipball/3b59adeef77fb1c03ff5381dbb9d68b0aaff3171", + "reference": "3b59adeef77fb1c03ff5381dbb9d68b0aaff3171", "shasum": "" }, "require": { @@ -3558,13 +4923,12 @@ "phpunit/phpunit": "^4.8.36 || ^5.7.21 || ^6.0 || ^7.0 || ^8.0 || ^9.0" }, "require-dev": { - "yoast/yoastcs": "^2.2.0" + "yoast/yoastcs": "^2.3.0" }, "type": "library", "extra": { "branch-alias": { - "dev-main": "1.x-dev", - "dev-develop": "1.x-dev" + "dev-main": "2.x-dev" } }, "autoload": { @@ -3598,7 +4962,7 @@ "issues": "https://github.com/Yoast/PHPUnit-Polyfills/issues", "source": "https://github.com/Yoast/PHPUnit-Polyfills" }, - "time": "2021-11-23T01:37:03+00:00" + "time": "2023-03-30T23:39:05+00:00" }, { "name": "yosymfony/resource-watcher", diff --git a/phpunit.xml.dist b/phpunit.xml.dist index 7a77c15b..76d1de2f 100644 --- a/phpunit.xml.dist +++ b/phpunit.xml.dist @@ -30,7 +30,9 @@ --> + ./providers wporg-two-factor.php + class-encrypted-totp-provider.php settings/settings.php settings/rest-api.php diff --git a/providers/webauthn/class-ajax.php b/providers/webauthn/class-ajax.php new file mode 100644 index 00000000..a513006a --- /dev/null +++ b/providers/webauthn/class-ajax.php @@ -0,0 +1,194 @@ +admin_init(); + } + + public function admin_init(): void { + add_action( 'wp_ajax_webauthn_preregister', [ $this, 'wp_ajax_webauthn_preregister' ] ); + add_action( 'wp_ajax_webauthn_register', [ $this, 'wp_ajax_webauthn_register' ] ); + add_action( 'wp_ajax_webauthn_delete_key', [ $this, 'wp_ajax_webauthn_delete_key' ] ); + add_action( 'wp_ajax_webauthn_rename_key', [ $this, 'wp_ajax_webauthn_rename_key' ] ); + } + + private function verify_nonce( string $nonce ): void { + if ( false === check_ajax_referer( $nonce, false, false ) ) { + wp_send_json_error( __( 'The nonce has expired. Please reload the page and try again.', 'two-factor-provider-webauthn' ), 400 ); + } + } + + private function check_registration_nonce( WP_User $user ): void { + $this->verify_nonce( "webauthn-register_key_{$user->ID}" ); + } + + public function wp_ajax_webauthn_preregister(): void { + $user = wp_get_current_user(); + $this->check_registration_nonce( $user ); + + try { + $server = Utils::create_webauthn_server(); + $settings = Settings::instance(); + + $reg_options = RegistrationOptions::createForUser( WebAuthn_User::get_for( $user ) ); + $reg_options->setExcludeExistingCredentials( true ); + $reg_options->setUserVerification( $settings->get_user_verification_requirement() ); + + if ( $settings->get_authenticator_attachment() ) { + $reg_options->setAuthenticatorAttachment( $settings->get_authenticator_attachment() ); + } + + if ( $settings->get_timeout() ) { + $reg_options->setTimeout( $settings->get_timeout() * 1000 ); + } + + $options = $server->startRegistration( $reg_options ); + + $context = $options->getContext(); + // phpcs:ignore WordPress.PHP.DiscouragedPHPFunctions.serialize_serialize + update_user_meta( $user->ID, self::REGISTRATION_CONTEXT_USER_META, base64_encode( serialize( $context ) ) ); + wp_send_json_success( [ + 'options' => $options->getClientOptionsJson(), + 'nonce' => wp_create_nonce( "webauthn-register_key_{$user->ID}" ), + ] ); + } catch ( Throwable $e ) { + wp_send_json_error( $e->getMessage() ); + } + } + + /** + * @global wpdb $wpdb + */ + public function wp_ajax_webauthn_register(): void { + $user = wp_get_current_user(); + $this->check_registration_nonce( $user ); + + try { + $server = Utils::create_webauthn_server(); + $context = (string) get_user_meta( $user->ID, self::REGISTRATION_CONTEXT_USER_META, true ); + /** @var mixed */ + $context = unserialize( base64_decode( $context ) ); // phpcs:ignore WordPress.PHP.DiscouragedPHPFunctions.serialize_unserialize + if ( ! ( $context instanceof RegistrationContext ) ) { + throw new UnexpectedValueException( __( 'Unable to retrieve the registration context.', 'two-factor-provider-webauthn' ) ); + } + + // We cannot use WordPress sanitization functions here: the credential must not be altered. + // We validate that `credential` is a string, valid JSON, and decodes to an object (associative array in terms of PHP). + // If any of the conditions does not hold, we fail the request. + // The webauthn-server library performs further validation in accordance with the specification. + // phpcs:ignore WordPress.Security.NonceVerification.Missing, WordPress.Security.ValidatedSanitizedInput.InputNotSanitized + $credential = $_POST['credential'] ?? null; // Dangerous to sanitize; the code will validate the value + if ( ! is_string( $credential ) ) { + throw new InvalidArgumentException( __( 'Bad request.', 'two-factor-provider-webauthn' ) ); + } + + /** @var mixed */ + $credential = json_decode( wp_unslash( $credential ), true, 512, JSON_THROW_ON_ERROR ); + if ( is_array( $credential ) ) { + $result = $server->finishRegistration( + JsonConverter::decodeCredential( $credential, 'attestation' ), + $context + ); + + // phpcs:ignore WordPress.Security.NonceVerification.Missing -- false positive, check_registration_nonce() does that + $name = sanitize_text_field( (string) ( $_POST['name'] ?? '' ) ); + $store = new WebAuthn_Credential_Store(); + $key = $store->save_user_key( $name, $result ); + if ( null === $key ) { + if ( defined( 'DEBUG_TFPWA' ) && true === constant( 'DEBUG_TFPWA' ) ) { + /** @var wpdb $wpdb */ + /** @psalm-suppress InvalidGlobal */ + global $wpdb; + $last_query = $wpdb->last_query; + $last_error = $wpdb->last_error; + + /** @var string */ + $credential = wp_json_encode( [ + 'user_handle' => $result->getUserHandle()->toString(), + 'credential_id' => $result->getCredentialId()->toString(), + 'public_key' => $result->getPublicKey()->toString(), + 'counter' => $result->getSignatureCounter(), + 'name' => $name ?: __( 'New Key', 'two-factor-provider-webauthn' ), + 'added' => time(), + 'last_used' => time(), + 'u2f' => 0, + ] ); + + // phpcs:ignore WordPress.PHP.DevelopmentFunctions.error_log_error_log + error_log( sprintf( 'Unable to save the key to the database. Last query: %s, last error: %s, credential: %s', $last_query, $last_error, $credential ) ); + throw new UnexpectedValueException( + "Unable to save the key to the database.\n" + . "Last query: {$last_query}\n" + . "Last error: {$last_error}\n" + . "Credential: {$credential}" + ); + } + + throw new UnexpectedValueException( __( 'Unable to save the key to the database.', 'two-factor-provider-webauthn' ) ); + } + + $table = new Key_Table( $user ); + ob_start(); + $table->single_row( (object) $key ); + $row = ob_get_clean(); + + wp_send_json_success( [ + 'row' => $row, + 'nonce' => wp_create_nonce( "webauthn-register_key_{$user->ID}" ), + ] ); + } else { + throw new InvalidArgumentException( __( 'Bad request.', 'two-factor-provider-webauthn' ) ); + } + } catch ( Throwable $e ) { + wp_send_json_error( $e->getMessage(), 400 ); + } finally { + delete_user_meta( $user->ID, self::REGISTRATION_CONTEXT_USER_META ); + } + } + + public function wp_ajax_webauthn_delete_key(): void { + // phpcs:ignore WordPress.Security.NonceVerification.Missing -- verify_nonce() checks the nonce + $handle = sanitize_text_field( (string) ( $_POST['handle'] ?? '' ) ); + $this->verify_nonce( "delete-key_{$handle}" ); + + $store = new WebAuthn_Credential_Store(); + $store->delete_user_key( wp_get_current_user(), $handle ); + wp_send_json_success(); + } + + public function wp_ajax_webauthn_rename_key(): void { + // phpcs:ignore WordPress.Security.NonceVerification.Missing -- verify_nonce() checks the nonce + $handle = sanitize_text_field( (string) ( $_POST['handle'] ?? '' ) ); + $this->verify_nonce( "rename-key_{$handle}" ); + + // phpcs:ignore WordPress.Security.NonceVerification.Missing -- verify_nonce() checks the nonce + $name = wp_unslash( sanitize_text_field( (string) ( $_POST['name'] ?? '' ) ) ); + if ( empty( $name ) ) { + wp_send_json_error( __( 'Key name cannot be empty.', 'two-factor-provider-webauthn' ), 400 ); + } + + $store = new WebAuthn_Credential_Store(); + $success = $store->rename_key( wp_get_current_user(), $handle, $name ); + if ( $success ) { + wp_send_json_success( [ 'name' => $name ] ); + } + + wp_send_json_error( __( 'Failed to rename the key.', 'two-factor-provider-webauthn' ), 400 ); + } +} diff --git a/providers/webauthn/class-two-factor-webauthn.php b/providers/webauthn/class-two-factor-webauthn.php new file mode 100644 index 00000000..92919645 --- /dev/null +++ b/providers/webauthn/class-two-factor-webauthn.php @@ -0,0 +1,833 @@ +webauthn = new WebAuthnHandler( $this->get_app_id() ); + + $this->key_store = WebAuthnKeyStore::instance(); + + add_action( 'admin_enqueue_scripts', array( __CLASS__, 'register_assets' ) ); + add_action( 'login_enqueue_scripts', array( __CLASS__, 'register_assets' ) ); + + add_action( 'wp_ajax_webauthn_preregister', [ $this, 'wp_ajax_webauthn_preregister' ] ); + add_action( 'wp_ajax_webauthn_register', [ $this, 'wp_ajax_webauthn_register' ] ); + + // add_action( 'wp_ajax_webauthn-register', array( $this, 'ajax_register' ) ); + add_action( 'wp_ajax_webauthn-edit-key', array( $this, 'ajax_edit_key' ) ); + add_action( 'wp_ajax_webauthn-delete-key', array( $this, 'ajax_delete_key' ) ); + add_action( 'wp_ajax_webauthn-test-key', array( $this, 'ajax_test_key' ) ); + + + add_action( 'two_factor_user_options_' . __CLASS__, array( $this, 'user_options' ) ); + + parent::__construct(); + + } + + public static function register_assets( $hook ) { + wp_register_script( + 'webauthn-login', + plugins_url( 'webauthn-login.js', __FILE__ ), + array( 'jquery' ), + filemtime( __DIR__ . '/webauthn-login.js' ), + true + ); + + // wp_register_script( + // 'webauthn-admin', + // plugins_url( 'webauthn-admin.js', __FILE__ ), + // array( 'jquery' ), + // filemtime( __DIR__ . '/webauthn-admin.js' ), + // true + // ); + + wp_register_script( + 'webauthn-register-key', + plugins_url( 'profile.js', __FILE__ ), + array( 'jquery' ), + filemtime( __DIR__ . '/profile.js' ), + true + ); + + if ( in_array( $hook, array( 'user-edit.php', 'profile.php' ), true ) ) { + $user = wp_get_current_user(); + + wp_localize_script( + 'webauthn-register-key', + 'tfa_webauthn', + array( + 'options' => array(), // todo populate w/ getClientOptionsJson() + 'nonce' => wp_create_nonce( "webauthn-register_key_{$user->ID}" ) + ), + ); + } + + + wp_register_style( + 'webauthn-admin', + plugins_url( 'webauthn-admin.css', __FILE__ ), + array(), + filemtime( __DIR__ . '/webauthn-admin.css' ) + ); + + wp_register_style( + 'webauthn-login', + plugins_url( 'webauthn-login.css', __FILE__ ), + array(), + filemtime( __DIR__ . '/webauthn-login.css' ) + ); + } + + /** + * Return the U2F AppId. WebAuthn requires the AppID + * to be the current domain or a suffix of it. + * + * @return string AppID FQDN + */ + public function get_app_id() { + + $url_parts = wp_parse_url( network_site_url() ); + + $app_id = $url_parts['host']; + + if ( ! empty( $url_parts['port'] ) ) { + $app_id = sprintf( '%s:%d', $app_id, $url_parts['port'] ); + } + + /** + * Filter the WebAuthn App ID. + * + * In order for this to work, the App-ID has to be either the current + * (sub-)domain or a suffix of it. + * + * @param string $app_id Domain name acting as relying party ID. + */ + return apply_filters( 'two_factor_webauthn_app_id', $app_id ); + + } + + + /** + * Returns the name of the provider. + * + * @return string + */ + public function get_label() { + return _x( 'Web Authentication (FIDO2)', 'Provider Label', 'two-factor' ); + } + + /** + * Prints the form that prompts the user to authenticate. + * + * @param WP_User $user WP_User object of the logged-in user. + * @return null + */ + public function authentication_page( $user ) { + + wp_enqueue_style( 'webauthn-login' ); + + require_once ABSPATH . '/wp-admin/includes/template.php'; + + // WebAuthn doesn't work without HTTPS. + if ( ! is_ssl() ) { + ?> +

+ key_store->get_keys( $user->ID ); + + $auth_opts = $this->webauthn->prepareAuthenticate( $keys ); + + update_user_meta( $user->ID, self::LOGIN_USERMETA, 1 ); + } catch ( Exception $e ) { + ?> +

+ 'webauthn-login', + 'payload' => $auth_opts, + '_wpnonce' => wp_create_nonce( 'webauthn-login' ), + ) + ); + + wp_enqueue_script( 'webauthn-login' ); + + ?> +

+ + +
+

+ + + +

+
+
+

+ + + +

+
+ key_store->get_keys( $user->ID ); + + $auth = $this->webauthn->authenticate( $credential, $keys ); + + if ( false === $auth ) { + return false; + } + $auth->last_used = time(); + $this->key_store->save_key( $user->ID, $auth, $auth->md5id ); + delete_user_meta( $user->ID, self::LOGIN_USERMETA ); + + return true; + } + + /** + * Whether this Two Factor provider is configured and available for the user specified. + * + * @param WP_User $user WP_User object of the logged-in user. + * @return boolean + */ + public function is_available_for_user( $user ) { + // only works for currently logged in user. + return (bool) count( $this->key_store->get_keys( $user->ID ) ); + } + + /** + * Inserts markup at the end of the user profile field for this provider. + * + * @param WP_User $user WP_User object of the logged-in user. + */ + public function user_options( $user ) { + + wp_enqueue_script( 'webauthn-register-key' ); + wp_enqueue_style( 'webauthn-admin' ); + + $challenge = $this->webauthn->prepareRegister( $user->display_name, $user->user_login ); + + $create_data = array( + 'action' => 'webauthn-register', + 'payload' => $challenge, + 'userId' => $user->ID, + '_wpnonce' => wp_create_nonce( 'webauthn-register' ), + ); + + $keys = $this->key_store->get_keys( $user->ID ); + + ?> +

+ +

+ +
+ +
+ + + + + webauthn->register( $credential, '' ); + + if ( false === $key ) { + wp_send_json_error( new WP_Error( 'webauthn', $this->webauthn->getLastError() ) ); + } + /* translators: %s webauthn app id (domain) */ + $key->label = sprintf( esc_html__( 'New Device - %s', 'two-factor' ), $this->get_app_id() ); + $key->md5id = md5( implode( '', array_map( 'chr', $key->id ) ) ); + $key->created = time(); + $key->last_used = false; + $key->tested = false; + + if ( false !== $this->key_store->key_exists( $key->md5id ) ) { + wp_send_json_error( new WP_Error( 'webauthn', esc_html__( 'Device already Exists', 'two-factor' ) ) ); + exit(); + } + + $this->key_store->create_key( $user_id, $key ); + + } catch ( Exception $err ) { + wp_send_json( + array( + 'success' => false, + 'error' => $err->getMessage(), + ) + ); + return; + } + + wp_send_json( + array( + 'success' => true, + 'html' => $this->get_key_item( $key, $user_id ), + ) + ); + } + + /** + * Edit Key Ajax Callback. + */ + public function ajax_edit_key() { + + check_ajax_referer( 'webauthn-edit-key' ); + + if ( ! isset( $_REQUEST['payload'] ) ) { + // Error couldn't decode. + wp_send_json_error( new WP_Error( 'webauthn', __( 'Invalid request', 'two-factor' ) ) ); + } + + $current_user_id = get_current_user_id(); + + if ( isset( $_REQUEST['user_id'] ) ) { + $user_id = intval( wp_unslash( $_REQUEST['user_id'] ) ); + } else { + wp_send_json_error( new WP_Error( 'webauthn', __( 'Invalid request data', 'two-factor' ) ) ); + } + // Not permitted. + if ( ! current_user_can( 'edit_users' ) && $user_id !== $current_user_id ) { + wp_send_json_error( new WP_Error( 'webauthn', __( 'Operation not permitted', 'two-factor' ) ) ); + } + + $payload = wp_unslash( $_REQUEST['payload'] ); + + if ( ! isset( $payload['md5id'], $payload['label'] ) ) { + wp_send_json_error( new WP_Error( 'webauthn', esc_html__( 'Invalid request', 'two-factor' ) ) ); + } + $new_label = sanitize_text_field( $payload['label'] ); + + if ( empty( $new_label ) ) { + wp_send_json_error( new WP_Error( 'webauthn', esc_html__( 'Invalid label', 'two-factor' ) ) ); + } + + $key = $this->key_store->find_key( $user_id, $payload['md5id'] ); + if ( ! $key ) { + wp_send_json_error( new WP_Error( 'webauthn', esc_html__( 'No such key', 'two-factor' ) ) ); + } + + $key->label = $new_label; + + if ( $this->key_store->save_key( $user_id, $key, $payload['md5id'] ) ) { + wp_send_json( + array( + 'success' => true, + ) + ); + } + + wp_send_json_error( new WP_Error( 'webauthn', esc_html__( 'Could not edit key', 'two-factor' ) ) ); + } + + /** + * Delete Key Ajax Callback. + */ + public function ajax_delete_key() { + + check_ajax_referer( 'webauthn-delete-key' ); + + if ( ! isset( $_REQUEST['payload'] ) ) { + + // Error couldn't decode. + wp_send_json_error( new WP_Error( 'webauthn', __( 'Invalid request', 'two-factor' ) ) ); + } + + $key_id = wp_unslash( $_REQUEST['payload'] ); + + $current_user_id = get_current_user_id(); + + if ( isset( $_REQUEST['user_id'] ) ) { + $user_id = intval( wp_unslash( $_REQUEST['user_id'] ) ); + } else { + $user_id = $current_user_id; + } + + // Not permitted. + if ( ! current_user_can( 'edit_users' ) && $user_id !== $current_user_id ) { + wp_send_json_error( new WP_Error( 'webauthn', __( 'Operation not permitted', 'two-factor' ) ) ); + } + + if ( $this->key_store->delete_key( $user_id, $key_id ) ) { + wp_send_json( + array( + 'success' => true, + ) + ); + } + + wp_send_json_error( new WP_Error( 'webauthn', esc_html__( 'Could not delete key', 'two-factor' ) ) ); + } + + /** + * Test Key Ajax Callback. + */ + public function ajax_test_key() { + + check_ajax_referer( 'webauthn-test-key' ); + + if ( ! isset( $_REQUEST['payload'] ) ) { + // error couldn't decode. + wp_send_json_error( new WP_Error( 'webauthn', __( 'Invalid request', 'two-factor' ) ) ); + } + + $credential = wp_unslash( $_REQUEST['payload'] ); + + $current_user_id = get_current_user_id(); + + if ( isset( $_REQUEST['user_id'] ) ) { + $user_id = intval( wp_unslash( $_REQUEST['user_id'] ) ); + } else { + wp_send_json_error( new WP_Error( 'webauthn', __( 'Invalid request data', 'two-factor' ) ) ); + } + + // Not permitted. + if ( ! current_user_can( 'edit_users' ) && $user_id !== $current_user_id ) { + wp_send_json_error( new WP_Error( 'webauthn', __( 'Operation not permitted', 'two-factor' ) ) ); + } + + $keys = $this->key_store->get_keys( $user_id ); + + $key = $this->webauthn->authenticate( json_decode( $credential ), $keys ); + // todo madwiz + + if ( false !== $key ) { + // store key tested state. + $key->tested = true; + $this->key_store->save_key( $user_id, $key, $key->md5id ); + } + + wp_send_json( + array( + 'success' => false !== $key, + 'message' => $this->webauthn->getLastError(), + ) + ); + } + + /** + * Key Row HTML. + * + * @param object $pub_key Public key as generated by $this->webauthn->register(). + * @param int $user_id User ID. + * @return string HTML. + */ + private function get_key_item( $pub_key, $user_id ) { + + $out = '
  • '; + + // Info. + $out .= sprintf( + '%2$s', + esc_attr( + wp_json_encode( + array( + 'action' => 'webauthn-edit-key', + 'payload' => $pub_key->md5id, + 'userId' => $user_id, + '_wpnonce' => wp_create_nonce( 'webauthn-edit-key' ), + ) + ) + ), + esc_html( $pub_key->label ) + ); + + $date_format = _x( 'm/d/Y', 'Short date format', 'two-factor' ); + + $out .= sprintf( + '%s
    %s
    ', + __( 'Created:', 'two-factor' ), + date_i18n( $date_format, $pub_key->created ) + ); + $out .= sprintf( + '%s
    %s
    ', + __( 'Last used:', 'two-factor' ), + $pub_key->last_used ? date_i18n( $date_format, $pub_key->last_used ) : esc_html__( '- Never -', 'two-factor' ) + ); + + // Actions. + $out .= sprintf( + ' + %1$s + + ', + esc_html__( 'Test', 'two-factor' ), + esc_attr( + wp_json_encode( + array( + 'action' => 'webauthn-test-key', + 'payload' => $this->webauthn->prepareAuthenticate( array( $pub_key ) ), + // todo change to madwiz + 'userId' => $user_id, + '_wpnonce' => wp_create_nonce( 'webauthn-test-key' ), + ) + ) + ), + $pub_key->tested ? 'tested' : 'untested' + ); + $out .= sprintf( + ' + + %1$s + ', + esc_html__( 'Delete', 'two-factor' ), + esc_attr( + wp_json_encode( + array( + 'action' => 'webauthn-delete-key', + 'payload' => $pub_key->md5id, + 'userId' => $user_id, + '_wpnonce' => wp_create_nonce( 'webauthn-delete-key' ), + ) + ) + ) + ); + $out .= '
  • '; + + return $out; + } + + + + + + + + + + + public function wp_ajax_webauthn_preregister(): void { + // die('x'); + $user = wp_get_current_user(); + + wp_send_json_success( + array( + 'options' => array( 'foo' => 'bar' ), + 'nonce' => wp_create_nonce( "webauthn-register_key_{$user->ID}" ), + ) + ); + + ///////////////// end stub + + + $user = wp_get_current_user(); + $this->check_registration_nonce( $user ); + + try { + $server = Utils::create_webauthn_server(); + $settings = Settings::instance(); + + $reg_options = RegistrationOptions::createForUser( WebAuthn_User::get_for( $user ) ); + $reg_options->setExcludeExistingCredentials( true ); + $reg_options->setUserVerification( $settings->get_user_verification_requirement() ); + + if ( $settings->get_authenticator_attachment() ) { + $reg_options->setAuthenticatorAttachment( $settings->get_authenticator_attachment() ); + } + + if ( $settings->get_timeout() ) { + $reg_options->setTimeout( $settings->get_timeout() * 1000 ); + } + + $options = $server->startRegistration( $reg_options ); + + $context = $options->getContext(); + // phpcs:ignore WordPress.PHP.DiscouragedPHPFunctions.serialize_serialize + update_user_meta( $user->ID, self::REGISTRATION_CONTEXT_USER_META, base64_encode( serialize( $context ) ) ); + wp_send_json_success( + array( + 'options' => $options->getClientOptionsJson(), + 'nonce' => wp_create_nonce( "webauthn-register_key_{$user->ID}" ), + ) + ); + } catch ( Throwable $e ) { + wp_send_json_error( $e->getMessage() ); + } + } + + /** + * @global wpdb $wpdb + */ + public function wp_ajax_webauthn_register(): void { + $user = wp_get_current_user(); + + wp_send_json_success( + array( + 'row' => ' hello! ', + 'nonce' => wp_create_nonce( "webauthn-register_key_{$user->ID}" ), + ) + ); + + + ///////////////// end stub + + $user = wp_get_current_user(); + $this->check_registration_nonce( $user ); + + try { + $server = Utils::create_webauthn_server(); + $context = (string) get_user_meta( $user->ID, self::REGISTRATION_CONTEXT_USER_META, true ); + /** @var mixed */ + $context = unserialize( base64_decode( $context ) ); // phpcs:ignore WordPress.PHP.DiscouragedPHPFunctions.serialize_unserialize + if ( ! ( $context instanceof RegistrationContext ) ) { + throw new UnexpectedValueException( __( 'Unable to retrieve the registration context.', 'two-factor-provider-webauthn' ) ); + } + + // We cannot use WordPress sanitization functions here: the credential must not be altered. + // We validate that `credential` is a string, valid JSON, and decodes to an object (associative array in terms of PHP). + // If any of the conditions does not hold, we fail the request. + // The webauthn-server library performs further validation in accordance with the specification. + // phpcs:ignore WordPress.Security.NonceVerification.Missing, WordPress.Security.ValidatedSanitizedInput.InputNotSanitized + $credential = $_POST['credential'] ?? null; // Dangerous to sanitize; the code will validate the value + if ( ! is_string( $credential ) ) { + throw new InvalidArgumentException( __( 'Bad request.', 'two-factor-provider-webauthn' ) ); + } + + /** @var mixed */ + $credential = json_decode( wp_unslash( $credential ), true, 512, JSON_THROW_ON_ERROR ); + if ( is_array( $credential ) ) { + $result = $server->finishRegistration( + JsonConverter::decodeCredential( $credential, 'attestation' ), + $context + ); + + $name = Utils::get_post_field_as_string( 'name' ); + $store = new WebAuthn_Credential_Store(); + $key = $store->save_user_key( $name, $result ); + if ( null === $key ) { + if ( defined( 'DEBUG_TFPWA' ) && true === constant( 'DEBUG_TFPWA' ) ) { + /** @var wpdb $wpdb */ + /** @psalm-suppress InvalidGlobal */ + global $wpdb; + $last_query = $wpdb->last_query; + $last_error = $wpdb->last_error; + + /** @var string */ + $credential = wp_json_encode( + array( + 'user_handle' => $result->getUserHandle()->toString(), + 'credential_id' => $result->getCredentialId()->toString(), + 'public_key' => $result->getPublicKey()->toString(), + 'counter' => $result->getSignatureCounter(), + 'name' => $name ?: __( 'New Key', 'two-factor-provider-webauthn' ), + 'added' => time(), + 'last_used' => time(), + 'u2f' => 0, + ) + ); + + // phpcs:ignore WordPress.PHP.DevelopmentFunctions.error_log_error_log + error_log( sprintf( 'Unable to save the key to the database. Last query: %s, last error: %s, credential: %s', $last_query, $last_error, $credential ) ); + throw new UnexpectedValueException( + "Unable to save the key to the database.\n" + . "Last query: {$last_query}\n" + . "Last error: {$last_error}\n" + . "Credential: {$credential}" + ); + } + + throw new UnexpectedValueException( __( 'Unable to save the key to the database.', 'two-factor-provider-webauthn' ) ); + } + + $table = new Key_Table( $user ); + ob_start(); + $table->single_row( (object) $key ); + $row = ob_get_clean(); + + wp_send_json_success( + array( + 'row' => $row, + 'nonce' => wp_create_nonce( "webauthn-register_key_{$user->ID}" ), + ) + ); + } else { + throw new InvalidArgumentException( __( 'Bad request.', 'two-factor-provider-webauthn' ) ); + } + } catch ( Throwable $e ) { + wp_send_json_error( $e->getMessage(), 400 ); + } finally { + delete_user_meta( $user->ID, self::REGISTRATION_CONTEXT_USER_META ); + } + } + +} diff --git a/providers/webauthn/class-utils.php b/providers/webauthn/class-utils.php new file mode 100644 index 00000000..3a875473 --- /dev/null +++ b/providers/webauthn/class-utils.php @@ -0,0 +1,36 @@ + $params + */ + public static function render( string $view, array $params = [] ): void { + /** @psalm-suppress UnresolvableInclude */ + require __DIR__ . '/../views/' . $view . '.php'; + } + + public static function create_webauthn_server(): ServerInterface { + $builder = new ServerBuilder(); + $builder->setRelyingParty( new RelyingParty( get_bloginfo( 'name' ), self::get_u2f_app_id() ) ); + $builder->setCredentialStore( new WebAuthn_Credential_Store() ); + $builder->enableExtensions( 'appid' ); + return $builder->build(); + } +} diff --git a/providers/webauthn/class-webauthn-credential-store.php b/providers/webauthn/class-webauthn-credential-store.php new file mode 100644 index 00000000..bb1e1d04 --- /dev/null +++ b/providers/webauthn/class-webauthn-credential-store.php @@ -0,0 +1,186 @@ +get_row( $wpdb->prepare( "SELECT * FROM {$wpdb->webauthn_credentials} WHERE credential_id = %s", $id->toString() ) ); + } + + public function findCredential( CredentialId $credentialId ): ?UserCredentialInterface { + $credential = $this->get_credential_by_id( $credentialId ); + if ( $credential ) { + return new WebAuthn_User_Credential( + $credentialId, + CoseKey::fromString( $credential->public_key ), + UserHandle::fromString( $credential->user_handle ) + ); + } + + return null; + } + + public function getSignatureCounter( CredentialId $credentialId ): ?int { + $credential = $this->get_credential_by_id( $credentialId ); + return $credential ? (int) $credential->counter : null; + } + + /** + * @global wpdb $wpdb + */ + public function updateSignatureCounter( CredentialId $credentialId, int $counter ): void { + /** @var wpdb $wpdb */ + global $wpdb; + + $wpdb->update( + $wpdb->webauthn_credentials, + [ 'counter' => $counter ], + [ 'credential_id' => $credentialId->toString() ], + '%d', + '%s' + ); + } + + /** + * @global wpdb $wpdb + * @return CredentialId[] + */ + public function getUserCredentialIds( UserHandle $userHandle ): array { + /** @var wpdb $wpdb */ + global $wpdb; + + /** @psalm-var object{credential_id: string}[] */ + $ids = $wpdb->get_results( $wpdb->prepare( "SELECT credential_id FROM {$wpdb->webauthn_credentials} WHERE user_handle = %s", $userHandle->toString() ) ); + return array_map( fn ( $c ): CredentialId => CredentialId::fromString( $c->credential_id ), $ids ); + } + + /** + * @psalm-return CredentialRow[] + * @global wpdb $wpdb + */ + public static function get_user_keys( WP_User $user ): array { + /** @var wpdb $wpdb */ + global $wpdb; + + $handle = WebAuthn_User::get_for( $user )->getUserHandle(); + /** @psalm-var CredentialRow[] $modern */ + $modern = $wpdb->get_results( $wpdb->prepare( "SELECT * FROM {$wpdb->webauthn_credentials} WHERE user_handle = %s", $handle->toString() ) ); + if ( empty( $modern ) ) { + /** @var array */ + $legacy = get_user_meta( $user->ID, self::REGISTERED_KEY_LEGACY_META ); + if ( ! empty( $legacy ) ) { + Credential_Migrator::migrate( $user, $handle ); + /** @psalm-var CredentialRow[] $modern */ + $modern = $wpdb->get_results( $wpdb->prepare( "SELECT * FROM {$wpdb->webauthn_credentials} WHERE user_handle = %s", $handle->toString() ) ); + } + } + + return $modern; + } + + /** + * @psalm-return CredentialRowArray|null + * @global wpdb $wpdb + */ + public function save_user_key( string $key_name, RegistrationResultInterface $result ): ?array { + /** @var wpdb $wpdb */ + global $wpdb; + + /** @psalm-var CredentialRowArray */ + $credential = [ + 'user_handle' => $result->getUserHandle()->toString(), + 'credential_id' => $result->getCredentialId()->toString(), + 'public_key' => $result->getPublicKey()->toString(), + 'counter' => $result->getSignatureCounter(), + 'name' => $key_name ?: __( 'New Key', 'two-factor-provider-webauthn' ), + 'added' => time(), + 'last_used' => time(), + 'u2f' => 0, + ]; + + $result = $wpdb->insert( $wpdb->webauthn_credentials, $credential, [ '%s', '%s', '%s', '%d', '%s', '%d', '%d', '%d' ] ); + return false !== $result ? $credential : null; + } + + /** + * @global wpdb $wpdb + */ + public function delete_user_key( WP_User $user, string $credential_id ): void { + /** @var wpdb $wpdb */ + global $wpdb; + + $handle = WebAuthn_User::get_for( $user )->getUserHandle(); + $wpdb->delete( + $wpdb->webauthn_credentials, + [ + 'user_handle' => $handle->toString(), + 'credential_id' => $credential_id, + ], + [ '%s', '%s' ] + ); + } + + /** + * @global wpdb $wpdb + */ + public function update_last_used_date( CredentialId $credentialId, int $when ): void { + /** @var wpdb $wpdb */ + global $wpdb; + + $wpdb->update( + $wpdb->webauthn_credentials, + [ 'last_used' => $when ], + [ 'credential_id' => $credentialId->toString() ], + '%d', + '%s' + ); + } + + /** + * @global wpdb $wpdb + */ + public function rename_key( WP_User $user, string $credential_id, string $new_name ): bool { + /** @var wpdb $wpdb */ + global $wpdb; + + $handle = WebAuthn_User::get_for( $user )->getUserHandle(); + $affected = $wpdb->update( + $wpdb->webauthn_credentials, + [ 'name' => $new_name ], + [ + 'user_handle' => $handle->toString(), + 'credential_id' => $credential_id, + ], + '%s', + '%s' + ); + + return $affected > 0; + } +} diff --git a/providers/webauthn/class-webauthn-user-credential.php b/providers/webauthn/class-webauthn-user-credential.php new file mode 100644 index 00000000..9e554bf1 --- /dev/null +++ b/providers/webauthn/class-webauthn-user-credential.php @@ -0,0 +1,32 @@ +credential_id = $credential_id; + $this->public_key = $public_key; + $this->user_handle = $user_handle; + } + + public function getCredentialId(): CredentialId { + return $this->credential_id; + } + + public function getPublicKey(): CoseKeyInterface { + return $this->public_key; + } + + public function getUserHandle(): UserHandle { + return $this->user_handle; + } +} diff --git a/providers/webauthn/class-webauthn-user.php b/providers/webauthn/class-webauthn-user.php new file mode 100644 index 00000000..0c0cee2a --- /dev/null +++ b/providers/webauthn/class-webauthn-user.php @@ -0,0 +1,88 @@ +user = $user; + } + + /** + * @throws NotAvailableException + * @global wpdb $wpdb + */ + public function getUserHandle(): UserHandle { + /** @var wpdb $wpdb */ + global $wpdb; + + $key = sprintf( 'handle:%u', $this->user->ID ); + /** @var mixed */ + $handle = wp_cache_get( $key, self::CACHE_GROUP_NAME ); + if ( false === $handle || ! is_string( $handle ) ) { + $handle = $wpdb->get_var( $wpdb->prepare( "SELECT user_handle FROM {$wpdb->webauthn_users} WHERE user_id = %d", $this->user->ID ) ); + if ( ! $handle ) { + $handle = UserHandle::random()->toString(); + $result = $wpdb->insert( + $wpdb->webauthn_users, + [ + 'user_id' => $this->user->ID, + 'user_handle' => $handle, + ], + [ '%d', '%s' ] + ); + + if ( false === $result ) { + throw new UnexpectedValueException( __( 'Unable to save the user handle to the database.', 'two-factor-provider-webauthn' ) ); + } + } + + wp_cache_set( $key, $handle, self::CACHE_GROUP_NAME, 3600 ); + } + + return UserHandle::fromString( $handle ); + } + + public function getUsername(): string { + return $this->user->user_login; + } + + public function getDisplayName(): string { + return $this->user->display_name; + } + + /** + * @global wpdb $wpdb + */ + public static function get_user_by_handle( UserHandle $handle ): ?WP_User { + /** @var wpdb $wpdb */ + global $wpdb; + + $key = sprintf( 'user:%s', $handle->toString() ); + /** @var mixed */ + $user_id = wp_cache_get( $key, self::CACHE_GROUP_NAME ); + if ( false === $user_id || ! is_int( $user_id ) ) { + /** @psalm-var numeric-string|null $user_id */ + $user_id = $wpdb->get_var( $wpdb->prepare( "SELECT user_id FROM {$wpdb->webauthn_users} WHERE user_handle = %s", $handle->toString() ) ); + wp_cache_set( $key, (int) $user_id, self::CACHE_GROUP_NAME, 3600 ); + } + + return $user_id ? new WP_User( (int) $user_id ) : null; + } +} diff --git a/providers/webauthn/includes/class-cbor-decoder.php b/providers/webauthn/includes/class-cbor-decoder.php new file mode 100644 index 00000000..de663fed --- /dev/null +++ b/providers/webauthn/includes/class-cbor-decoder.php @@ -0,0 +1,296 @@ + "C", + self::ADDITIONAL_TYPE_INT_UINT16 => "n", + self::ADDITIONAL_TYPE_INT_UINT32 => "N", + self::ADDITIONAL_TYPE_INT_UINT64 => null, + ); + + private static $float_pack_type = array( + self::ADDITIONAL_TYPE_FLOAT32 => "f", + self::ADDITIONAL_TYPE_FLOAT64 => "d", + ); + + private static $byte_length = array( + self::ADDITIONAL_TYPE_INT_UINT8 => 1, + self::ADDITIONAL_TYPE_INT_UINT16 => 2, + self::ADDITIONAL_TYPE_INT_UINT32 => 4, + self::ADDITIONAL_TYPE_INT_UINT64 => 8, + ); + + /** + * Decode CBOR byte string + * @param mixed $var + * @throws \Exception + * @return mixed + */ + public static function decode(&$var){ + $out = null; + + //get initial byte + $unpacked = unpack("C*", substr($var, 0, 1)); + $header_byte = array_shift($unpacked); + + if ($header_byte == self::MAJOR_TYPE_INFINITE_CLOSE) { + $major_type = $header_byte; + $additional_info = 0; + } else { + //unpack major type + $major_type = $header_byte & self::ADDITIONAL_WIPE; + //get additional_info + $additional_info = self::unpack_additional_info($header_byte); + } + + $byte_data_offset = 1; + if(array_key_exists($additional_info, self::$byte_length)){ + $byte_data_offset += self::$byte_length[$additional_info]; + } + + switch($major_type) { + case self::MAJOR_TYPE_UNSIGNED_INT: + case self::MAJOR_TYPE_INT: + //decode int + $out = self::decode_int($additional_info, $var); + + if($major_type == self::MAJOR_TYPE_INT){ + $out = -($out+1); + } + + break; + case self::MAJOR_TYPE_BYTE_STRING: + case self::MAJOR_TYPE_UTF8_STRING: + $string_length = self::decode_int($additional_info, $var); + + $out = substr($var, $byte_data_offset, $string_length); + + if($major_type == self::MAJOR_TYPE_BYTE_STRING) { + $out = new CBORByteString($out); + } + + $byte_data_offset += $string_length; + break; + case self::MAJOR_TYPE_ARRAY: + case self::MAJOR_TYPE_MAP: + $out = array(); + + $elem_count = $additional_info != self::ADDITIONAL_TYPE_INFINITE ? + self::decode_int($additional_info, $var) : PHP_INT_MAX; + $var = substr($var, $byte_data_offset); + + while($elem_count > count($out)) + { + $primitive = self::decode($var); + if (is_null($primitive)) { + break; + } + if($major_type == self::MAJOR_TYPE_MAP) { + $out[$primitive] = self::decode($var); + } else { + $out[] = $primitive; + } + } + + break; + case self::MAJOR_TYPE_TAGS: + throw new \Exception("Not implemented. Sorry"); + break; + case self::MAJOR_TYPE_SIMPLE_AND_FLOAT: + $out = self::decode_simple_float($additional_info, $var); + break; + case self::MAJOR_TYPE_INFINITE_CLOSE: + $out = null; + } + + if(!in_array($major_type, array(self::MAJOR_TYPE_ARRAY, self::MAJOR_TYPE_MAP))){ + $var = substr($var, $byte_data_offset); + } + + return $out; + } + + /** + * Unpack data length/int + * @param $length_capacity + * @param $byte_string + * @throws Exception + * @internal param $length + * @return int|null + */ + private static function decode_int($length_capacity, &$byte_string){ + + if($length_capacity <= self::ADDITIONAL_MAX) return $length_capacity; + $decoding_byte_string = substr($byte_string, 1, self::$byte_length[$length_capacity]); + switch(true) + { + case $length_capacity == self::ADDITIONAL_TYPE_INT_UINT64: + return self::bigint_unpack($decoding_byte_string); + break; + case array_key_exists($length_capacity, self::$length_pack_type): + $typed_int = unpack(self::$length_pack_type[$length_capacity], $decoding_byte_string); + return array_shift($typed_int); + break; + default: + throw new Exception("CBOR Incorrect additional info"); + break; + } + + return null; + } + + /** + * Unpack double/bool/null + * @param $length_capacity + * @param $byte_string + * @return null|string + */ + private static function decode_simple_float($length_capacity, &$byte_string){ + $simple_association = array( + self::ADDITIONAL_TYPE_INT_FALSE => false, + self::ADDITIONAL_TYPE_INT_TRUE => true, + self::ADDITIONAL_TYPE_INT_NULL => null, + self::ADDITIONAL_TYPE_INT_UNDEFINED => NAN, + ); + + if(array_key_exists($length_capacity, $simple_association)) + { + return $simple_association[$length_capacity]; + } + $typed_float = unpack(self::$float_pack_type[$length_capacity], strrev(substr($byte_string, 1, self::$byte_length[$length_capacity]))); + return array_shift($typed_float); + } + + /** + * Unpack additional info + * @param $byte + * @return int + */ + private static function unpack_additional_info($byte) + { + return $byte & self::HEADER_WIPE; + } + + /** + * Pack initial byte NOT IN USE + * @param $major_type + * @param $additional_info + * @return string + */ + private static function pack_init_byte($major_type, $additional_info) + { + return pack("c", $major_type | $additional_info); + } + + /** + * Get length of int NOT IN USE + * @param $int + * @return int|null + */ + private static function get_length($int) + { + switch(true) + { + case $int < 256: + return self::ADDITIONAL_TYPE_INT_UINT8; + break; + case $int < 65536: + return self::ADDITIONAL_TYPE_INT_UINT16; + break; + case $int < 4294967296: + return self::ADDITIONAL_TYPE_INT_UINT32; + break; + //are you seriously? + case $int < 9223372036854775807: + return null; + break; + } + return null; + } + + /** + * Array is associative or not + * + * @param $arr + * @return bool + */ + private static function is_assoc(&$arr) + { + return array_keys($arr) !== range(0, count($arr) -1); + } + + /** + * Split big int in two 32 bit parts and pack + * @param $big_int + * @return string + */ + private static function bigint_unpack($big_int) + { + list($higher, $lower) = array_values(unpack("N2", $big_int)); + return $higher << 32 | $lower; + } + + private static function bigint_pack($big_int) + { + return pack("NN", ($big_int & 0xffffffff00000000) >> 32, ($big_int & 0x00000000ffffffff)); + } +} + + +class CBORByteString { + private $byte_string = null; + + public function __construct($byte_string) + { + $this->byte_string = $byte_string; + } + + /** + * @return null + */ + public function get_byte_string() + { + return $this->byte_string; + } + + /** + * @param null $byte_string + */ + public function set_byte_string($byte_string) + { + $this->byte_string = $byte_string; + } +} diff --git a/providers/webauthn/includes/class-webauthn-handler.php b/providers/webauthn/includes/class-webauthn-handler.php new file mode 100644 index 00000000..67f016c7 --- /dev/null +++ b/providers/webauthn/includes/class-webauthn-handler.php @@ -0,0 +1,750 @@ + false, + 'prepareAuthenticate' => false, + 'register' => false, + 'prepareRegister' => false, + ); + + const ES256 = -7; + const RS256 = -257; // Windows Hello support + + /** + * construct object on which to operate + * + * @param string $appid a string identifying your app, typically the domain of your website which people + * are using the key to log in to. If you have the URL (ie including the + * https:// on the front) to hand, give that; + * if it's not https, well what are you doing using this code? + */ + public function __construct($appid) + { + if (! is_string($appid)) { + throw new Exception('appid must be a string'); + } + $this->appid = $appid; + if (strpos($this->appid, 'https://') === 0) { + $this->appid = substr($this->appid, 8); /* drop the https:// */ + } + } + + /** + * Return last error depending on request + */ + public function getLastError( string $realm = NULL ) { + if ( is_null( $realm ) ) { + $realm = $this->last_call; + } + if ( is_null( $realm ) ) { + return false; + } + if ( ! isset( $this->last_error[ $realm ] ) ) { + return false; + } + return $this->last_error[ $realm ]; + } + + /** + * generate a challenge ready for registering a hardware key, fingerprint or whatever: + * @param $username string by which the user is known potentially displayed on the hardware key + * @param $userid string by which the user can be uniquely identified. Don't use email address as this can change, + * user perhaps the database record id + * @param $crossPlatform bool default=FALSE, whether to link the identity to the key (TRUE, so it + * can be used cross-platofrm, on different computers) or the platform (FALSE, only on + * this computer, but with any available authentication device, e.g. known to Windows Hello) + * @return string pass this JSON string back to the browser + */ + public function prepareRegister($username, $userid, $crossPlatform = FALSE) + { + $result = (object) array(); + $rbchallenge = self::randomBytes(16); + $result->challenge = self::stringToArray($rbchallenge); + $result->user = (object) array(); + $result->user->name = $result->user->displayName = $username; + $result->user->id = self::stringToArray($userid); + + $result->rp = (object) array(); + $result->rp->name = $result->rp->id = $this->appid; + + $result->pubKeyCredParams = array( + array( + 'alg' => self::ES256, + 'type' => 'public-key' + ), + array( + 'alg' => self::RS256, + 'type' => 'public-key' + ) + ); + + $result->authenticatorSelection = (object) array(); + if ( $crossPlatform ) { + $result->authenticatorSelection->authenticatorAttachment = 'cross-platform'; + } + + $result->authenticatorSelection->requireResidentKey = false; + $result->authenticatorSelection->userVerification = 'discouraged'; + + $result->attestation = null; + $result->timeout = 60000; + $result->excludeCredentials = array(); // No excludeList + $result->extensions = (object) array(); + $result->extensions->exts = true; + + return array( + 'publicKey' => $result, + 'b64challenge' => rtrim( strtr( base64_encode( $rbchallenge ), '+/', '-_'), '=') + ); + } + + /** + * registers a new key for a user + * requires info from the hardware via javascript given below + * @param object $info supplied to the PHP script via a POST, constructed by the Javascript given below, ultimately + * provided by the key + * @param string $userwebauthn the exisitng webauthn field for the user from your + * database (it's actaully a JSON string, but that's entirely internal to + * this code) + * @return boolean|object user key + */ + public function register( object $info ) { + + $this->last_call = __FUNCTION__; + + $this->last_error[ $this->last_call ] = false; + + // check info + if ( false === $this->validateRegisterInfo( $info ) ) { + // error generated in validateRegisterInfo() + return false; + } + + /* check response from key and store as new identity. This is a hex string representing the raw CBOR + attestation object received from the key */ + + $attData = $this->parseAttestationObject( $info->response->attestationObject ); + + // check info + if ( false === $attData ) { + // error generated in parseAttestationObject() + return false; + } + + if ( $attData->credId !== self::arrayToString( $info->rawId ) ) { + $this->last_error[ $this->last_call ] = 'ao-id-mismatch'; + return false; + } + + return (object) array( + 'key' => $attData->keyBytes, + 'id' => $info->rawId, + ); + + } + + /** + * generates a new key string for the physical key, fingerprint + * reader or whatever to respond to on login + * @param array $userKeys the existing webauthn field for the user from your database + * @return boolean|object Object to pass to javascript webauthnAuthenticate or false on faliue + */ + public function prepareAuthenticate( array $userKeys = array() ) + { + $allowKeyDefaults = array( + 'transports' => array( 'usb','nfc','ble','internal' ), + 'type' => 'public-key', + ); + $allows = array(); + foreach ( $userKeys as $key) { + if ( $this->isValidKey( $key ) ) { + $allows[] = (object) ( array( + 'id' => $key->id, + ) + $allowKeyDefaults ); + } + } + + if ( ! count( $allows ) ) { + /* including empty user, so they can't tell whether the user exists or not (need same result each + time for each user) */ + $rb = md5( (string) time() ); + $allows[] = (object) (array( + 'id' => self::stringToArray( $rb ), + ) + $allowKeyDefaults); + } + + /* generate key request */ + $publickey = (object) array(); + $publickey->challenge = self::stringToArray( self::randomBytes(16) ); + $publickey->timeout = 60000; + $publickey->allowCredentials = $allows; + $publickey->userVerification = 'discouraged'; + $publickey->extensions = (object) array(); + // $publickey->extensions->txAuthSimple = 'Execute order 66'; + $publickey->rpId = str_replace('https://', '', $this->appid ); + + return $publickey; + } + + /** + * validates a response for login or 2fa + * requires info from the hardware via javascript given below + * @param object $info supplied to the PHP script via POST, constructed by the Javascript given below, ultimately + * provided by the key + * @param array $userKeys the exisiting webauthn field for the user from your + * database + * @return object|null the matching key object from $userKeys for a valid authentication, null otherwise + */ + public function authenticate( object $info, array $userKeys ) + { + + $this->last_call = __FUNCTION__; + + $this->last_error[ $this->last_call ] = false; + + // check info + if ( ! $this->validateAuthenticateInfo( $info ) ) { + return false; + } + + $key = $this->findKeyById( $info->rawId, $userKeys ); + + if ( false === $key ) { + $this->last_error[ $this->last_call ] = 'no-matching-key'; + return false; + } + + + $bs = self::arrayToString( $info->response->authenticatorData ); + $ao = (object)array(); + + $ao->rpIdHash = substr( $bs, 0, 32 ); + $ao->flags = ord( substr( $bs, 32, 1 ) ); + $ao->counter = substr( $bs, 33, 4 ); + + $hashId = hash( 'sha256', $this->appid, true ); + + if ( $hashId !== $ao->rpIdHash ) { + $this->last_error[ $this->last_call ] = 'key-response-decode-hash-mismatch'; + return false; + } + + /* experience shows that at least one device (OnePlus 6T/Pie (Android phone)) doesn't set this, + so this test would fail. This is not correct according to the spec, so pragmatically it may + have to be removed */ + if ( ( $ao->flags & 0x1 ) != 0x1 ) { + $this->last_error[ $this->last_call ] = 'key-response-decode-flags-mismatch'; + return false; + } /* only TUP must be set */ + + /* assemble signed data */ + $clientdata = self::arrayToString( $info->response->clientDataJSONarray ); + $signeddata = $hashId . chr( $ao->flags ) . $ao->counter . hash( 'sha256', $clientdata, true ); + + if (count( $info->response->signature ) < 70) { + $this->last_error[ $this->last_call ] = 'key-response-decode-signature-invalid'; + return false; + } + + $signature = self::arrayToString($info->response->signature); + + $verify_result = openssl_verify( $signeddata, $signature, $key->key, OPENSSL_ALGO_SHA256 ); + + if ( 1 === $verify_result ) { + $this->last_error[ $this->last_call ] = false; + return $key; + } else if ( 0 === $verify_result ) { + $this->last_error[ $this->last_call ] = 'key-not-verfied'; + return false; + } + + $this->last_error[ $this->last_call ] = openssl_error_string(); + + return false; + + } + + /** + * Parse and validate Attestation object + * + * @param array $ao_arr Attestation Object byte array + * @return boolean|object attestedCredentialData false on failure + * + * @see https://developer.mozilla.org/en-US/docs/Web/API/AuthenticatorAssertionResponse/authenticatorData + */ + private function parseAttestationObject( array $ao_arr ) { + + // + $ao_cbor = self::arrayToString( $ao_arr ); + /** + * Fires before an attestiation object is parsed + * + * @param String $ao_cbor Byte string + */ + do_action( 'two_factor_webauthn_parse_attestation_object', $ao_cbor ); + $ao = (object)( CBORDecoder::decode( $ao_cbor ) ); + + // begin validation + if ( ! is_object( $ao ) ) { + $this->last_error[ $this->last_call ] = 'ao-not-object'; + return false; + } + + if ( empty( $ao ) ) { + $this->last_error[ $this->last_call ] = 'ao-empty'; + return false; + } + + if ( ! isset( $ao->fmt, $ao->authData ) ) { + $this->last_error[ $this->last_call ] = 'ao-missing-property'; + return false; + } + + if ( ! is_string( $ao->fmt ) ) { + $this->last_error[ $this->last_call ] = 'ao-fmt-invalid'; + return false; + } + if ( ! ( $ao->authData instanceof CBORByteString ) ) { + $this->last_error[ $this->last_call ] = 'ao-authdata-invalid'; + return false; + } + + if ( ! in_array( $ao->fmt, array( 'none', 'packed' ) ) ) { + $this->last_error[ $this->last_call ] = 'ao-fmt-unsupported'; + return false; + } + + $bs = $ao->authData->get_byte_string(); + /** + * Fires before an attestiation object is parsed + * + * @param String $ao_cbor Byte string + */ + do_action( 'two_factor_webauthn_parse_auth_data', $bs ); + + if ( empty( $bs ) ) { + $this->last_error[ $this->last_call ] = 'ao-authdata-empty'; + return false; + } + + // + $authData = (object) array( + 'rpIdHash' => substr($bs, 0, 32), + 'flags' => ord(substr($bs, 32, 1)), + 'signCount' => substr($bs, 33, 4), + ); + + if ( ! ( $authData->flags & 0x41 ) ) { + $this->last_error[ $this->last_call ] = 'ao-flags-unsupported'; + return false; + } + + $hashId = hash('sha256', $this->appid, true); + + if ( $hashId != $authData->rpIdHash ) { + $this->last_error[ $this->last_call ] = 'ao-appid-mismatch'; + return false; + } + + $attData = (object) array( + 'aaguid' => substr($bs, 37, 16), + 'credIdLen' => ( ord( $bs[53] ) << 8 ) + ord( $bs[54] ), + ); + + $attData->credId = substr( $bs, 55, $attData->credIdLen ); + $attData->keyBytes = self::COSEECDHAtoPKCS( + substr( $bs, 55 + $attData->credIdLen ) + ); + + return $attData; + + } + + /** + * Validates First argument of authenticate. + * @param object $info + * @return boolean + */ + private function validateRegisterInfo( object $info ) { + /* + $info + ->rawId Uint8Array + ->response + ->attestationObject Uint8Array : CBOR + + */ + if ( ! isset( $info->rawId, $info->response ) ) { + $this->last_error[ $this->last_call ] = 'info-missing-property'; + return false; + } + if ( ! is_array( $info->rawId ) || ! is_object( $info->response ) ) { + $this->last_error[ $this->last_call ] = 'info-malformed-property'; + return false; + } + if ( ! isset( $info->response->attestationObject ) ) { + $this->last_error[ $this->last_call ] = 'info-response-missing-property'; + return false; + } + if ( ! is_array( $info->response->attestationObject ) ) { + $this->last_error[ $this->last_call ] = 'info-response-malformed-property'; + return false; + } + + return true; + + } + + + + + /** + * Validates First argument of authenticate. + * @param object $info + * @return boolean + */ + private function validateAuthenticateInfo( object $info ) { + /* + $info + ->rawId array Uint8Array + ->originalChallenge Uint8Array + ->response + ->clientData + ->challenge base64string + ->origin string URL + ->type string 'webauthn.get' + ->clientDataJSONarray Uint8Array + ->authenticatorData Uint8Array + ->signature Uint8Array + */ + // check existence 1st level + if ( ! isset( $info->rawId, $info->originalChallenge, $info->response ) ) { + $this->last_error[ $this->last_call ] = 'info-missing-property'; + return false; + } + // check types 1st level + if ( ! is_array( $info->rawId ) || ! is_array( $info->originalChallenge ) || ! is_object( $info->response ) ) { + $this->last_error[ $this->last_call ] = 'info-malformed-value'; + return false; + } + + // check existence 2nd level + if ( ! isset( $info->response->clientData, $info->response->clientDataJSONarray, $info->response->authenticatorData, $info->response->signature ) ) { + $this->last_error[ $this->last_call ] = 'info-response-missing-property'; + return false; + } + // check types 2nd level + if ( ! is_object( $info->response->clientData ) || ! is_array( $info->response->clientDataJSONarray ) || ! is_array( $info->response->authenticatorData ) || ! is_array( $info->response->signature ) ) { + $this->last_error[ $this->last_call ] = 'info-response-malformed-value'; + return false; + } + + // check existence 3rd level + if ( ! isset( + $info->response->clientData->challenge, + $info->response->clientData->origin, + $info->response->clientData->type + ) + ) { + $this->last_error[ $this->last_call ] = 'info-clientdata-missing-property'; + return false; + } + + // check types 3rd level + if ( + ! is_string( $info->response->clientData->challenge ) || + ! is_string( $info->response->clientData->origin ) || + ! is_string( $info->response->clientData->type ) + ) { + $this->last_error[ $this->last_call ] = 'info-clientdata-malformed-value'; + return false; + } + + if ( $info->response->clientData->type != 'webauthn.get') { + $this->last_error[ $this->last_call ] = "info-wrong-type"; + return false; + } + + + /* cross-check challenge */ + if ( $info->response->clientData->challenge + !== + rtrim( strtr( base64_encode( self::arrayToString( $info->originalChallenge ) ), '+/', '-_'), '=') + ) { + $this->last_error[ $this->last_call ] = 'info-challenge-mismatch'; + return false; + } + + /* cross check origin */ + $origin = parse_url( $info->response->clientData->origin ); + + if ( strpos( $origin['host'], $this->appid ) !== ( strlen( $origin['host'] ) - strlen( $this->appid ) ) ) { + + $this->last_error[ $this->last_call ] = 'info-origin-mismatch'; + return false; + } + + + return true; + + + } + + + /** + * Find key by ID + * @param array $keyId + * @param array $keys Contains key objects (object) [ 'id' => [ int, int, ...], 'key' => '-----BEGIN PUBLIC KEY--...' ] + */ + private function findKeyById( array $keyId, array $keys ) { + + $keyIdString = implode( ',', $keyId ); + + foreach ( $keys as $key ) { + // check for key format + if ( ! $this->isValidKey( $key ) ) { + continue; + } + if ( implode(',', $key->id ) === $keyIdString ) { + return $key; + } + } + return false; + } + + /** + * @param object $key + * @return boolean + */ + private function isValidKey( $key ) { + return is_object( $key ) && isset( $key->id ) && is_array( $key->id ) && isset( $key->key ) && is_string( $key->key ); + } + + + /** + * convert an array of uint8's to a binary string + * @param array $a to be converted (array of unsigned 8 bit integers) + * @return string converted to bytes + */ + private static function arrayToString($a) + { + $s = ''; + foreach ($a as $c) { + $s .= chr($c); + } + return $s; + } + + /** + * convert a binary string to an array of uint8's + * @param string $s to be converted + * @return array converted to array of unsigned integers + */ + private static function stringToArray($s) + { + /* convert binary string to array of uint8 */ + $a = array(); + for ($idx = 0; $idx < strlen($s); $idx++) { + $a[] = ord($s[$idx]); + } + return $a; + } + + /** + * convert a public key from the hardware to PEM format + * @param string $key to be converted to PEM format + * @return string converted to PEM format + */ + private function pubkeyToPem($key) + { + /* see https://github.com/Yubico/php-u2flib-server/blob/master/src/u2flib_server/U2F.php */ + if (strlen($key) !== 65 || $key[0] !== "\x04") { + return null; + } + /* + * Convert the public key to binary DER format first + * Using the ECC SubjectPublicKeyInfo OIDs from RFC 5480 + * + * SEQUENCE(2 elem) 30 59 + * SEQUENCE(2 elem) 30 13 + * OID1.2.840.10045.2.1 (id-ecPublicKey) 06 07 2a 86 48 ce 3d 02 01 + * OID1.2.840.10045.3.1.7 (secp256r1) 06 08 2a 86 48 ce 3d 03 01 07 + * BIT STRING(520 bit) 03 42 ..key.. + */ + $der = "\x30\x59\x30\x13\x06\x07\x2a\x86\x48\xce\x3d\x02\x01"; + $der .= "\x06\x08\x2a\x86\x48\xce\x3d\x03\x01\x07\x03\x42"; + $der .= "\x00".$key; + $pem = "-----BEGIN PUBLIC KEY-----\x0A"; + $pem .= chunk_split(base64_encode($der), 64, "\x0A"); + $pem .= "-----END PUBLIC KEY-----\x0A"; + return $pem; + } + + /** + * Convert COSE ECDHA to PKCS + * @param string binary string to be converted + * @return string converted public key + */ + private function COSEECDHAtoPKCS($binary) + { + $cosePubKey = CBORDecoder::decode($binary); + + if (! isset($cosePubKey[3] /* cose_alg */)) { + throw new Exception('cannot decode key response (8)'); + } + + switch ($cosePubKey[3]) { + case self::ES256: + /* COSE Alg: ECDSA w/ SHA-256 */ + if (! isset($cosePubKey[-1] /* cose_crv */)) { + throw new Exception('cannot decode key response (9)'); + } + + if (! isset($cosePubKey[-2] /* cose_crv_x */)) { + throw new Exception('cannot decode key response (10)'); + } + + if ($cosePubKey[-1] != 1 /* cose_crv_P256 */) { + throw new Exception('cannot decode key response (14)'); + } + + if (!isset($cosePubKey[-2] /* cose_crv_x */)) { + throw new Exception('x coordinate for curve missing'); + } + + if (! isset($cosePubKey[1] /* cose_kty */)) { + throw new Exception('cannot decode key response (7)'); + } + + if (! isset($cosePubKey[-3] /* cose_crv_y */)) { + throw new Exception('cannot decode key response (11)'); + } + + if (!isset($cosePubKey[-3] /* cose_crv_y */)) { + throw new Exception('y coordinate for curve missing'); + } + + if ($cosePubKey[1] != 2 /* cose_kty_ec2 */) { + throw new Exception('cannot decode key response (12)'); + } + + $x = $cosePubKey[-2]->get_byte_string(); + $y = $cosePubKey[-3]->get_byte_string(); + if (strlen($x) != 32 || strlen($y) != 32) { + throw new Exception('cannot decode key response (15)'); + } + + $tag = "\x04"; + + $pem = $this->pubkeyToPem($tag.$x.$y); + + return $pem; + + case self::RS256: + /* COSE Alg: RSASSA-PKCS1-v1_5 w/ SHA-256 */ + if (!isset($cosePubKey[-2])) { + throw new Exception('RSA Exponent missing'); + } + if (!isset($cosePubKey[-1])) { + throw new Exception('RSA Modulus missing'); + } + + $pubkey = $this->getRSAPubkey( + $cosePubKey[-2]->get_byte_string(), + $cosePubKey[-1]->get_byte_string() + ); + + return $pubkey; + //*/ + default: + throw new Exception('cannot decode key response (13)'); + } + } + + /** + * + */ + private function getRSAPubkey( $publicExponent, $modulus ) { + // derived from + $components = array( + 'modulus' => pack('Ca*a*', 2, $this->derEncodeLength(strlen($modulus)), $modulus), + 'publicExponent' => pack('Ca*a*', 2, $this->derEncodeLength(strlen($publicExponent)), $publicExponent) + ); + $RSAPublicKey = pack( + 'Ca*a*a*', + 48, // ASN1 Sequence + $this->derEncodeLength(strlen($components['modulus']) + strlen($components['publicExponent'])), + $components['modulus'], + $components['publicExponent'] + ); + + // sequence(oid(1.2.840.113549.1.1.1), null)) = rsaEncryption. + $rsaOID = pack('H*', '300d06092a864886f70d0101010500'); // hex version of MA0GCSqGSIb3DQEBAQUA + $RSAPublicKey = chr(0) . $RSAPublicKey; + $RSAPublicKey = chr(3) . $this->derEncodeLength(strlen($RSAPublicKey)) . $RSAPublicKey; + + $RSAPublicKey = pack( + 'Ca*a*', + 48, + $this->derEncodeLength(strlen($rsaOID . $RSAPublicKey)), + $rsaOID . $RSAPublicKey + ); + + $RSAPublicKey = "-----BEGIN PUBLIC KEY-----\r\n" . + chunk_split(base64_encode($RSAPublicKey), 64) . + '-----END PUBLIC KEY-----'; + + return $RSAPublicKey; + + } + + /** + * DER-encode length + * {@link http://itu.int/ITU-T/studygroups/com17/languages/X.690-0207.pdf#p=13 X.690 paragraph 8.1.3} + * + * @param Integer $length + * @param String DES Encoded $length + */ + private function derEncodeLength($length) { + if ($length <= 0x7F) { + return chr($length); + } + + $temp = ltrim(pack('N', $length), chr(0)); + return pack('Ca*', 0x80 | strlen($temp), $temp); + + } + + + /** + * shim for random_bytes which doesn't exist pre php7 + * @param int $length the number of bytes required + * @return string cryptographically random bytes + */ + private static function randomBytes($length) + { + if (function_exists('random_bytes')) { + return random_bytes($length); + } else if (function_exists('openssl_random_pseudo_bytes')) { + $bytes = openssl_random_pseudo_bytes($length, $crypto_strong); + if (! $crypto_strong) { + throw new Exception("openssl_random_pseudo_bytes did not return a cryptographically strong result", 1); + } + return $bytes; + } else { + throw new Exception("Neither random_bytes not openssl_random_pseudo_bytes exists. PHP too old? openssl PHP extension not installed?", 1); + } + } + + +} diff --git a/providers/webauthn/includes/class-webauthn-keystore.php b/providers/webauthn/includes/class-webauthn-keystore.php new file mode 100644 index 00000000..6f7f2ca0 --- /dev/null +++ b/providers/webauthn/includes/class-webauthn-keystore.php @@ -0,0 +1,141 @@ +get_results( $wpdb->prepare( + "SELECT * FROM $wpdb->usermeta WHERE user_id=%d AND meta_key=%s AND meta_value LIKE %s", + $user_id, + self::PUBKEY_USERMETA_KEY, + '%' . $wpdb->esc_like( $keyLike ) . '%' + ) ); + foreach ( $found as $key ) { + return maybe_unserialize( $key->meta_value ); + } + return false; + } + + /** + * Check whether a key exists + * + * @param string $keyLike + * @return bool + */ + public function key_exists( $keyLike ) { + + global $wpdb; + + $num_keys = $wpdb->get_var( $wpdb->prepare( + "SELECT COUNT(*) FROM $wpdb->usermeta WHERE meta_key=%s AND meta_value LIKE %s", + self::PUBKEY_USERMETA_KEY, + '%' . $wpdb->esc_like( serialize( $keyLike ) ) . '%' + ) ); + + return intval( $num_keys ) !== 0; + + } + + /** + * Add key to user + * + * @param int $user_id + * @param string $key + * @return bool + */ + public function create_key( $user_id, $key ) { + if ( $this->find_key( $user_id, $key->md5id ) ) { + return false; + } + return add_user_meta( $user_id, self::PUBKEY_USERMETA_KEY, $key ); + } + + /** + * Add or update key for user + * + * @param int $user_id + * @param string $key The new Key + * @param string $keyLike The old Key to be updated + * @return bool + */ + public function save_key( $user_id, $key, $keyLike ) { + $oldKey = $this->find_key( $user_id, $keyLike ); + return update_user_meta( $user_id, self::PUBKEY_USERMETA_KEY, $key, $oldKey ); + } + + /** + * Delete key for user + * + * @param int $user_id + * @param string $keyLike The Key to be deleted + * @return bool + */ + public function delete_key( $user_id, $keyLike ) { + global $wpdb; + + if ( $key = $this->find_key( $user_id, $keyLike ) ) { + return delete_user_meta( $user_id, self::PUBKEY_USERMETA_KEY, $key ); + } + + return false; + } + + +} diff --git a/providers/webauthn/profile.js b/providers/webauthn/profile.js new file mode 100644 index 00000000..41095a46 --- /dev/null +++ b/providers/webauthn/profile.js @@ -0,0 +1,250 @@ +(function (jQuery, i18n) { + + function _extends() { + _extends = Object.assign ? Object.assign.bind() : function (target) { + for (var i = 1; i < arguments.length; i++) { + var source = arguments[i]; + for (var key in source) { + if (Object.prototype.hasOwnProperty.call(source, key)) { + target[key] = source[key]; + } + } + } + return target; + }; + return _extends.apply(this, arguments); + } + + i18n.__('WebAuthn is not supported by the browser.', 'two-factor-provider-webauthn'); + i18n.__('Unable to get a public key credential.', 'two-factor-provider-webauthn'); + var L_NOT_ALLOWED_ERROR = i18n.__('The request is not allowed.', 'two-factor-provider-webauthn'); + var L_SECURITY_ERROR = i18n.__('The operation is insecure.', 'two-factor-provider-webauthn'); + var L_NOT_SUPPORTED_ERROR = i18n.__('The operation is not supported.', 'two-factor-provider-webauthn'); + var L_ABORT_ERROR = i18n.__('The operation was canceled.', 'two-factor-provider-webauthn'); + var L_UNKNOWN_KEY = i18n.__('You cannot use this key to log in.', 'two-factor-provider-webauthn'); + var L_KEY_ALREADY_REGISTERED = i18n.__('This key is already registered.', 'two-factor-provider-webauthn'); + var L_UNKNOWN_ERROR = i18n.__('This key is already registered.', 'two-factor-provider-webauthn'); + var L_FETCHING_REG_INFO = i18n.__('Fetching registration information…', 'two-factor-provider-webauthn'); + var L_GENERATING_CREDENTIALS = i18n.__('Generating credentials…', 'two-factor-provider-webauthn'); + var L_REGISTERING_CREDENTIALS = i18n.__('Registering credentials…', 'two-factor-provider-webauthn'); + var L_FAILED_TO_CREATE_CREDENTIALS = i18n.__('Unable to create public key credentials', 'two-factor-provider-webauthn'); + var L_KEY_REGISTERED = i18n.__('The key has been registered.', 'two-factor-provider-webauthn'); + var L_SENDING_REQUEST = i18n.__('Sending request…', 'two-factor-provider-webauthn'); + var L_KEY_REVOKED = i18n.__('The key has been revoked.', 'two-factor-provider-webauthn'); + var L_KEY_RENAMED = i18n.__('The key has been renamed.', 'two-factor-provider-webauthn'); + + function arrayToBase64String(a) { + return window.btoa(String.fromCharCode.apply(String, a)); + } + function base64UrlDecode(input) { + return window.atob(input.replace(/-/g, '+').replace(/_/g, '/') + '='.repeat(3 - (3 + input.length) % 4)); + } + function stringToBuffer(s) { + return Uint8Array.from(s, function (c) { + return c.charCodeAt(0); + }); + } + function preparePublicKeyCreationOptions(publicKey) { + var _a; + return _extends({}, publicKey, { + user: _extends({}, publicKey.user, { + id: stringToBuffer(base64UrlDecode(publicKey.user.id)) + }), + challenge: stringToBuffer(base64UrlDecode(publicKey.challenge)), + excludeCredentials: (_a = publicKey.excludeCredentials) === null || _a === void 0 ? void 0 : _a.map(function (data) { + return _extends({}, data, { + id: stringToBuffer(base64UrlDecode(data.id)) + }); + }) + }); + } + function preparePublicKeyCredential(data) { + var response = data.response; + return { + id: data.id, + type: data.type, + rawId: arrayToBase64String(new Uint8Array(data.rawId)), + clientExtensionResults: data.getClientExtensionResults(), + response: { + attestationObject: 'attestationObject' in response ? arrayToBase64String(new Uint8Array(response.attestationObject)) : undefined, + authenticatorData: 'authenticatorData' in response ? arrayToBase64String(new Uint8Array(response.authenticatorData)) : undefined, + signature: 'signature' in response ? arrayToBase64String(new Uint8Array(response.signature)) : undefined, + userHandle: 'userHandle' in response && response.userHandle ? arrayToBase64String(new Uint8Array(response.userHandle)) : undefined, + clientDataJSON: arrayToBase64String(new Uint8Array(data.response.clientDataJSON)) + } + }; + } + function decodeDOMException(e, isAuth) { + switch (e.name) { + case 'NotAllowedError': + return L_NOT_ALLOWED_ERROR; + case 'SecurityError': + return L_SECURITY_ERROR; + case 'NotSupportedError': + return L_NOT_SUPPORTED_ERROR; + case 'AbortError': + return L_ABORT_ERROR; + case 'InvalidStateError': + return isAuth ? L_UNKNOWN_KEY : L_KEY_ALREADY_REGISTERED; + default: + return e.message; + } + } + + /* eslint-disable camelcase */ + function ajaxRequest(data) { + return new Promise(function (resolve, reject) { + jQuery.ajax({ + method: 'POST', + url: ajaxurl, + data: data + }).done(function (response) { + resolve(response); + }).fail(function (response) { + var message; + if (response.responseJSON) { + message = response.responseJSON.data || L_UNKNOWN_ERROR; + } else { + message = response.statusText; + } + reject(new Error(message)); + }); + }); + } + jQuery(function ($) { + var parent = $('#webauthn-security-keys-section'); + function updateStatus(status) { + if (!status) { + parent.find('.security-key-status').text(''); + } else { + parent.find('.security-key-status').html('

    ' + status + '

    '); + } + } + function errorHandler(e) { + var message = e instanceof DOMException ? decodeDOMException(e, false) : e.message; + var table = parent.find('.registered-keys'); + table.siblings('.notice').remove(); + table.before(''); + } + + function startRegistration( event ) { + event.preventDefault(); + + // const options = JSON.parse( event.target.dataset.createOptions ); // these are generated by mcguffin, so will need to replace w/ volod's + // console.log(options ); + + // todo: might need to re-enable or port these + // parent.find('.registered-keys').prev('.notice').remove(); + // updateStatus(L_FETCHING_REG_INFO); + + ajaxRequest({ + action: 'webauthn_preregister', // this is empty wtf, doesn't this need to be `webauthn_preregister`? + // _ajax`_nonce: options._wpnonce + _ajax_nonce: tfa_webauthn.nonce + }).then(function (response) { + updateStatus(L_GENERATING_CREDENTIALS); + tfa_webauthn.nonce = response.data.nonce; + var publicKey = preparePublicKeyCreationOptions(response.data.options); + return navigator.credentials.create({ + publicKey: publicKey + }); + }).then(function (c) { + if (c) { + updateStatus(L_REGISTERING_CREDENTIALS); + var name = $('#webauthn-key-name').val(); + return ajaxRequest({ + action: 'webauthn_register', + _ajax_nonce: tfa_webauthn.nonce, + credential: JSON.stringify(preparePublicKeyCredential(c)), + name: name + }); + } + throw new Error(L_FAILED_TO_CREATE_CREDENTIALS); + }).then(function (response) { + tfa_webauthn.nonce = response.data.nonce; + var table = parent.find('.registered-keys'); + table.find('tbody > tr:last-child').after(response.data.row); + table.find('tbody > tr.no-items').remove(); + table.before(''); + })["catch"](errorHandler)["finally"](function () { + updateStatus(''); + $('#webauthn-key-name').val(''); + }); + } + + // parent.find('.add-webauthn-key button').on('click', startRegistration); + const registerButton = document.getElementById( 'webauthn-register-key' ); + // console.log( {registerButton} ); + registerButton.addEventListener( 'click', startRegistration ); + + parent.find('.registered-keys').on('click', 'tbody .delete a', function (e) { + parent.find('.registered-keys').prev('.notice').remove(); + e.preventDefault(); + var a = $(e.target); + var actions = a.closest('.row-actions'); + if (actions.siblings('.confirm-revoke').length) { + return; + } + var handle = a.data('handle'); + var nonce = a.data('nonce'); + var table = parent.find('.registered-keys'); + var tpl = $($('#webauthn-revoke-confirm').text()); + actions.after(tpl); + actions.siblings('.confirm-revoke').on('click', '.button-secondary', function () { + actions.siblings('.confirm-revoke').remove(); + }).on('click', '.button-link-delete', function () { + actions.siblings('.confirm-revoke').hide(); + updateStatus(L_SENDING_REQUEST); + return ajaxRequest({ + action: 'webauthn_delete_key', + _ajax_nonce: nonce, + handle: handle + }).then(function () { + table.before(''); + a.closest('tr').remove(); + if (!table.find('tbody > tr').length) { + table.find('tbody').append($('#webauthn-no-keys').text()); + } + })["catch"](errorHandler)["finally"](function () { + updateStatus(''); + actions.siblings('.confirm-revoke').remove(); + }); + }); + }); + parent.find('.registered-keys').on('click', 'tbody .rename a', function (e) { + parent.find('.registered-keys').prev('.notice').remove(); + e.preventDefault(); + var a = $(e.target); + var actions = a.closest('.row-actions'); + if (actions.siblings('.rename-key').length) { + return; + } + var handle = a.data('handle'); + var nonce = a.data('nonce'); + var name = a.closest('td').find('span.key-name').text().trim(); + var table = parent.find('.registered-keys'); + var tpl = $($('#webauthn-rename-key').text()); + actions.after(tpl); + actions.siblings('.rename-key').on('click', '.button-secondary', function () { + actions.siblings('.rename-key').remove(); + }).on('click', '.button-primary', function () { + var keyname = actions.siblings('.rename-key').find('input[type="text"]').val(); + actions.siblings('.rename-key').hide(); + updateStatus(L_SENDING_REQUEST); + return ajaxRequest({ + action: 'webauthn_rename_key', + _ajax_nonce: nonce, + handle: handle, + name: keyname + }).then(function (r) { + table.before(''); + a.closest('td').find('span.key-name').text(r.data.name); + })["catch"](errorHandler)["finally"](function () { + updateStatus(''); + actions.siblings('.rename-key').remove(); + }); + }).find('input[type="text"]').val(name); + }); + }); + +})(jQuery, wp.i18n); diff --git a/providers/webauthn/webauthn-admin.css b/providers/webauthn/webauthn-admin.css new file mode 100644 index 00000000..775d4170 --- /dev/null +++ b/providers/webauthn/webauthn-admin.css @@ -0,0 +1,166 @@ +.webauth-register { + display: -webkit-box; + display: -ms-flexbox; + display: flex; + -webkit-box-align: center; + -ms-flex-align: center; + align-items: center; +} +.webauth-register .webauthn-error { + margin-left: 13px; +} +#webauthn-keys .busy, +.webauth-register .busy { + color: #7f8284; + pointer-events: none; + -webkit-transition: opacity 0.3s ease; + -o-transition: opacity 0.3s ease; + transition: opacity 0.3s ease; + background-size: 30px 30px; + background-image: -o-linear-gradient(45deg, rgba(0, 0, 0, 0.1) 25%, transparent 25%, transparent 50%, rgba(0, 0, 0, 0.1) 50%, rgba(0, 0, 0, 0.1) 75%, transparent 75%, transparent); + background-image: linear-gradient(45deg, rgba(0, 0, 0, 0.1) 25%, transparent 25%, transparent 50%, rgba(0, 0, 0, 0.1) 50%, rgba(0, 0, 0, 0.1) 75%, transparent 75%, transparent); + -webkit-animation: barberpole 0.5s linear infinite; + animation: barberpole 0.5s linear infinite; +} +@-webkit-keyframes barberpole { + from { + background-position: 0 0; + } + to { + background-position: 60px 30px; + } +} +@keyframes barberpole { + from { + background-position: 0 0; + } + to { + background-position: 60px 30px; + } +} +.webauthn-key { + display: -webkit-box; + display: -ms-flexbox; + display: flex; + -ms-flex-wrap: wrap; + flex-wrap: wrap; + -webkit-box-align: last baseline; + -ms-flex-align: last baseline; + align-items: last baseline; + border-top: 1px solid #ccc; +} +@media screen and (max-width: 600px) { + .webauthn-key { + -webkit-box-orient: vertical; + -webkit-box-direction: normal; + -ms-flex-direction: column; + flex-direction: column; + text-align: center; + } + .webauthn-key > * { + min-width: 100%; + margin-bottom: 6px; + } +} +.webauthn-key, +.webauthn-key * { + -webkit-box-sizing: border-box; + box-sizing: border-box; +} +.webauthn-key:last-of-type { + border-bottom: 1px solid #ccc; +} +.webauthn-key .webauthn-label { + -webkit-box-flex: 1; + -ms-flex: 1; + flex: 1; + white-space: normal; + padding: 3px; +} +@media screen and (max-width: 600px) { + .webauthn-key .webauthn-label { + font-size: 1.5em; + padding: 6px; + } +} +.webauthn-key .webauthn-label ~ * { + -webkit-box-flex: 0; + -ms-flex: 0; + flex: 0; +} +.webauthn-key .webauthn-created, +.webauthn-key .webauthn-used { + display: inline-block; + white-space: nowrap; + padding: 0 0.5em; +} +@media screen and (min-width: 600px) { + .webauthn-key .webauthn-created, + .webauthn-key .webauthn-used { + min-width: 100px; + } +} +.webauthn-key [data-tested="tested"] { + color: #0085ba; +} +.webauthn-key [data-tested="fail"] { + color: #dc3232; +} +.webauthn-key [data-tested="fail"]::before { + content: ""; +} +.webauthn-key [data-tested="untested"] { + color: #ccc; +} +.webauthn-key [data-tested="untested"]::before { + content: ""; + border-radius: 50%; + border: 1px solid #ccc; + font-size: 16px; +} +.webauthn-key .webauthn-action { + padding: 3px; + border: 1px solid rgba(0, 0, 0, 0); + text-decoration: none; +} +.webauthn-key .webauthn-action-link { + -webkit-box-flex: 0; + -ms-flex: 0; + flex: 0; +} +.webauthn-key .webauthn-action-link.-test, +.webauthn-key .webauthn-action-link.-delete { + white-space: nowrap; +} +@media screen and (max-width: 600px) { + .webauthn-key .webauthn-action-link.-test, + .webauthn-key .webauthn-action-link.-delete { + text-align: center; + } + .webauthn-key .webauthn-action-link.-test, + .webauthn-key .webauthn-action-link.-test ::before, + .webauthn-key .webauthn-action-link.-delete, + .webauthn-key .webauthn-action-link.-delete ::before { + font-size: 1.5em; + } +} +.webauthn-key .webauthn-action-link.-delete:hover { + color: #dc3232; +} +.webauthn-key > .webauthn-label { + word-break: break-word; +} +.webauthn-key > .webauthn-label:focus { + outline: none; +} +.webauthn-key > .webauthn-label[contenteditable="true"] { + background-color: #fff; + border-color: #0085ba; +} +.webauthn-key > .webauthn-label.busy { + border-color: #ccc; +} +.webauthn-key .notice { + -ms-flex-preferred-size: 100%; + flex-basis: 100%; +} diff --git a/providers/webauthn/webauthn-admin.js b/providers/webauthn/webauthn-admin.js new file mode 100644 index 00000000..a2e8c6fa --- /dev/null +++ b/providers/webauthn/webauthn-admin.js @@ -0,0 +1,374 @@ +( function( $ ) { + + /** + * Borrowed from https://github.com/davidearl/webauthn + */ + function webauthnRegister( key, callback ) { + + const publicKey = Object.assign( {}, key.publicKey ); + + publicKey.attestation = undefined; + publicKey.challenge = new Uint8Array( publicKey.challenge ); + publicKey.user.id = new Uint8Array( publicKey.user.id ); + + navigator.credentials.create( { publicKey } ) + .then( function( aNewCredentialInfo ) { + let cd, ao, rawId, info; + + cd = JSON.parse( String.fromCharCode.apply( null, new Uint8Array( aNewCredentialInfo.response.clientDataJSON ) ) ); + if ( key.b64challenge !== cd.challenge ) { + callback( false, 'key returned something unexpected (1)' ); + } + if ( ! ( 'type' in cd ) ) { + return callback( false, 'key returned something unexpected (3)' ); + } + if ( 'webauthn.create' != cd.type ) { + return callback( false, 'key returned something unexpected (4)' ); + } + + ao = []; + ( new Uint8Array( aNewCredentialInfo.response.attestationObject ) ).forEach( function( v ) { + ao.push( v ); + }); + rawId = []; + ( new Uint8Array( aNewCredentialInfo.rawId ) ).forEach( function( v ) { + rawId.push( v ); + }); + info = { + rawId: rawId, + id: aNewCredentialInfo.id, + type: aNewCredentialInfo.type, + response: { + attestationObject: ao, + clientDataJSON: + JSON.parse( String.fromCharCode.apply( null, new Uint8Array( aNewCredentialInfo.response.clientDataJSON ) ) ) + } + }; + callback( true, JSON.stringify( info ) ); + }) + .catch( err => { + if ( 'name' in err ) { + callback( false, err.name + ': ' + err.message ); + } else { + callback( false, err.toString() ); + } + }); + } + + /** + * Borrowed from https://github.com/davidearl/webauthn + */ + function webauthnAuthenticate( pubKeyAuth, callback ) { + + const originalChallenge = pubKeyAuth.challenge; + const pk = Object.assign( {}, pubKeyAuth ); + + pk.challenge = new Uint8Array( pubKeyAuth.challenge ); + pk.allowCredentials = pk.allowCredentials.map( k => { + const ret = Object.assign( {}, k ); + ret.id = new Uint8Array( k.id ); + return ret; + } ); + + /* Ask the browser to prompt the user */ + navigator.credentials.get( { publicKey: pk } ) + .then( aAssertion => { + let ida, cd, cda, ad, sig, info; + + ida = []; + ( new Uint8Array( aAssertion.rawId ) ).forEach( function( v ) { + ida.push( v ); + } ); + + cd = JSON.parse( String.fromCharCode.apply( null, + new Uint8Array( aAssertion.response.clientDataJSON ) ) ); + + cda = []; + ( new Uint8Array( aAssertion.response.clientDataJSON ) ).forEach( function( v ) { + cda.push( v ); + } ); + + ad = []; + ( new Uint8Array( aAssertion.response.authenticatorData ) ).forEach( function( v ) { + ad.push( v ); + } ); + + sig = []; + ( new Uint8Array( aAssertion.response.signature ) ).forEach( function( v ) { + sig.push( v ); + } ); + + info = { + type: aAssertion.type, + originalChallenge: originalChallenge, + rawId: ida, + response: { + authenticatorData: ad, + clientData: cd, + clientDataJSONarray: cda, + signature: sig + } + }; + + callback( true, JSON.stringify( info ) ); + }) + .catch( err => { + /* + FF mac: + InvalidStateError: key not found + AbortError: user aborted or denied + NotAllowedError: ? + The request is not allowed by the user agent or the platform in the current context, possibly because the user denied permission. + + Chrome mac: + NotAllowedError: user aborted or denied + + Safari mac: + NotAllowedError: user aborted or denied + + Edge win10: + UnknownError: wrong key...? + NotAllowedError: user aborted or denied + + FF win: + NotAllowedError: user aborted or denied + DOMException: "The request is not allowed by the user agent or the platform in the current context, possibly because the user denied permission." + */ + if ( 'name' in err ) { + callback( false, err.name + ': ' + err.message ); + } else { + callback( false, err.toString() ); + } + }); + }; + + /** + * @param ArrayBuffer arrayBuf + * @return Array + */ + const buffer2Array = arrayBuf => [ ... ( new Uint8Array( arrayBuf ) ) ]; + + const register = ( opts, callback ) => { + + const { action, userId, payload, _wpnonce } = opts; + + webauthnRegister( payload, ( success, info ) => { + if ( success ) { + $.ajax({ + url: wp.ajax.settings.url, + method: 'post', + data: { + action, + payload: info, + user_id: userId, + _wpnonce + }, + success: callback + }); + } else { + callback( { success: false, message: info } ); + } + } ); + }; + + const login = ( opts, callback ) => { + + const { action, payload, _wpnonce } = opts; + + // change to madwiz. maybe they have something you can just enqueue? and remove the func below altogether? + + webauthnAuthenticate( payload, ( success, info ) => { + if ( success ) { + callback( { success:true, result: info } ); + } else { + callback( { success:false, message: info } ); + } + }); + }; + + const sendRequest = ( opts, callback ) => { + + $.ajax( { + url: wp.ajax.settings.url, + method: 'post', + data: opts, + success:callback + } ); + }; + + const editKey = ( editLabel, opts, callback = () => {} ) => { + + const { + action, + payload, + _wpnonce, + userId + } = opts; + + const stopEditing = ( save = false ) => { + const newLabel = $( editLabel ).text(); + $( editLabel ).text( newLabel ); + $( editLabel ).prop( 'contenteditable', false ); + $( document ).off( 'keydown' ); + $( editLabel ).off( 'blur' ); + if ( save && prevLabel !== newLabel ) { + $( editLabel ).addClass( 'busy' ); + + sendRequest( + { + action, + payload: { + md5id: payload, + label: newLabel + }, + user_id: userId, + _wpnonce + }, + response => { + $( editLabel ).removeClass( 'busy' ); + callback( response ); + } + ); + } else if ( ! save ) { + $( editLabel ).text( prevLabel ); + } + }; + + const prevLabel = $( editLabel ).text(); + + $( editLabel ).prop( 'contenteditable', true ); + + $( document ).on( 'keydown', e => { + if ( 13 === e.which ) { + stopEditing( true ); + e.preventDefault(); + } else if ( 27 === e.which ) { + stopEditing( true ); + } + } ); + + // Focus and select + $( editLabel ) + .on( 'blur', e => stopEditing( true ) ) + .on( 'paste', e => { + e.preventDefault(); + let text = ( e.originalEvent || e ).clipboardData.getData( 'text/plain' ); + document.execCommand( 'insertHTML', false, text ); + } ); + + $( editLabel ).focus(); + + document.execCommand( 'selectAll', false, null ); + }; + + $( document ).on( 'click', '#webauthn-register-key', e => { + + e.preventDefault(); + + $( e.target ).next( '.webauthn-error' ).remove(); + + const $btn = $( e.target ).addClass( 'busy' ); + + const opts = JSON.parse( $( e.target ).attr( 'data-create-options' ) ); + + register( opts, response => { + $btn.removeClass( 'busy' ); + if ( response.success ) { + const $keyItem = $( response.html ).appendTo( '#webauthn-keys' ); + const $keyLabel = $keyItem.find( '.webauthn-label' ); + + editKey( + $keyLabel.get( 0 ), + JSON.parse( $keyLabel.attr( 'data-action' ) ) + ); + } else { + let msg; + if ( !! response.message ) { + msg = response.message; + } else if ( !! response.data && response.data[0] && response.data[0].message ) { + msg = response.data[0].message; + } else { + msg = JSON.stringify( response ); + } + $( `${msg}` ).insertAfter( '#webauthn-register-key' ); + } + }); + + }); + + if ( 'credentials' in navigator ) { + $( document ).on( 'click', '.webauthn-action', e => { + e.preventDefault(); + const $btn = $( e.target ).closest( '.webauthn-action' ); + const opts = JSON.parse( $btn.attr( 'data-action' ) ); + const $keyEl = $( e.target ).closest( '.webauthn-key' ); + const { + action, + userId, + payload, + _wpnonce + } = opts; + + + if ( 'webauthn-test-key' === action ) { + // debugger; // this causes ff to error out b/c takes too long to interact + + e.preventDefault(); + $keyEl.find( '.notice' ).remove(); + $btn.addClass( 'busy' ); + login( opts, result => { + if ( ! result.success ) { + $keyEl.append( `
    ${result.message}
    ` ); + $btn.removeClass( 'busy' ); + return; + } + + // Send to server + sendRequest( { + action, + user_id: userId, + payload: result.result, + _wpnonce + }, response => { + if ( response.success ) { + $btn.find( '[data-tested]' ).attr( 'data-tested', 'tested' ); + } else { + $btn.find( '[data-tested]' ).attr( 'data-tested', 'fail' ); + $keyEl.append( `
    ${response.data[0].message}
    ` ); + } + $btn.removeClass( 'busy' ); + } ); + } ); + } else if ( 'webauthn-delete-key' === action ) { + $keyEl.addClass( 'busy' ); + e.preventDefault(); + sendRequest( opts, function( response ) { + $keyEl.removeClass( 'busy' ); + + // Remove key from list + if ( response.success ) { + $keyEl.remove(); + } else { + + // Error from server + $keyEl.append( `
    ${response.data[0].message}
    ` ); + } + } ); + } + if ( 'webauthn-edit-key' === opts.action ) { + if ( 'true' !== $( e.currentTarget ).prop( 'contenteditable' ) ) { + e.preventDefault(); + editKey( e.currentTarget, opts, response => { + if ( ! response.success ) { + $keyEl.append( `
    ${response.data[0].message}
    ` ); + } + } ); + } + } + } ); + } else { + $( '.webauthn-unsupported' ).removeClass( 'hidden' ); + $( '.webauthn-supported' ).addClass( 'hidden' ); + } + +} )( jQuery ); diff --git a/providers/webauthn/webauthn-login.css b/providers/webauthn/webauthn-login.css new file mode 100644 index 00000000..452f10a9 --- /dev/null +++ b/providers/webauthn/webauthn-login.css @@ -0,0 +1,16 @@ +.webauthn-retry, +.webauthn-unsupported { + display:none; + margin-top:13px; +} +.webauthn-retry.visible, +.webauthn-unsupported.visible{ + display:block; +} +.webauthn-retry p, +.webauthn-unsupported p { + font-style:italic; +} +.webauthn-retry .button{ + float:right; +} diff --git a/providers/webauthn/webauthn-login.js b/providers/webauthn/webauthn-login.js new file mode 100644 index 00000000..b2daf63d --- /dev/null +++ b/providers/webauthn/webauthn-login.js @@ -0,0 +1,114 @@ +( function( $ ) { + /** + * Borrowed from https://github.com/davidearl/webauthn + */ + function webauthnAuthenticate( pubKeyAuth, callback ) { + + const originalChallenge = pubKeyAuth.challenge; + const pk = Object.assign( {}, pubKeyAuth ); + + pk.challenge = new Uint8Array( pubKeyAuth.challenge ); + pk.allowCredentials = pk.allowCredentials.map( k => { + const ret = Object.assign( {}, k ); + ret.id = new Uint8Array( k.id ); + return ret; + } ); + + /* Ask the browser to prompt the user */ + navigator.credentials.get( { publicKey: pk } ) + .then( aAssertion => { + let ida, cd, cda, ad, sig, info; + + ida = []; + ( new Uint8Array( aAssertion.rawId ) ).forEach( function( v ) { + ida.push( v ); + } ); + + cd = JSON.parse( String.fromCharCode.apply( null, + new Uint8Array( aAssertion.response.clientDataJSON ) ) ); + + cda = []; + ( new Uint8Array( aAssertion.response.clientDataJSON ) ).forEach( function( v ) { + cda.push( v ); + } ); + + ad = []; + ( new Uint8Array( aAssertion.response.authenticatorData ) ).forEach( function( v ) { + ad.push( v ); + } ); + + sig = []; + ( new Uint8Array( aAssertion.response.signature ) ).forEach( function( v ) { + sig.push( v ); + } ); + + info = { + type: aAssertion.type, + originalChallenge: originalChallenge, + rawId: ida, + response: { + authenticatorData: ad, + clientData: cd, + clientDataJSONarray: cda, + signature: sig + } + }; + + callback( true, JSON.stringify( info ) ); + }) + .catch( err => { + if ( 'name' in err ) { + callback( false, err.name + ': ' + err.message ); + } else { + callback( false, err.toString() ); + } + }); + }; + + const login = ( opts, callback ) => { + + const { action, payload, _wpnonce } = opts; + + webauthnAuthenticate( payload, ( success, info ) => { + if ( success ) { + callback( { success:true, result: info } ); + } else { + callback( { success:false, message: info } ); + } + }); + }; + + /** + * Some Password Managers (like nextcloud passwords) seem to abort the + * key browser dialog. + * We have to retry a couple of times to + */ + const auth = () => { + $( '.webauthn-retry' ).removeClass( 'visible' ); + login( window.webauthnL10n, response => { + if ( response.success ) { + $( '#webauthn_response' ).val( response.result ); + $( '#loginform' ).submit(); + } else { + + // Show retry-button + $( '.webauthn-retry' ).addClass( 'visible' ); + } + } ); + }; + + if ( ! window.webauthnL10n ) { + console.error( 'webauthL10n is not defined' ); + }; + + if ( 'credentials' in navigator ) { + $( document ) + .ready( auth ) + .on( 'click', '.webauthn-retry-link', auth ); + } else { + + // Show unsupported message + $( '.webauthn-unsupported' ).addClass( 'visible' ); + } + +} )( jQuery ); diff --git a/settings/settings.php b/settings/settings.php index 082bc09e..afbd1a81 100644 --- a/settings/settings.php +++ b/settings/settings.php @@ -25,10 +25,15 @@ function register_block() { * @codeCoverageIgnore */ function replace_core_ui_with_custom() : void { + /* + @todo Temporarily commented so that WebAuthn can be managed via wp-admin. Restore this when our custom WebAuthn UI is ready. + See https://github.com/WordPress/wporg-two-factor/issues/114, https://github.com/WordPress/wporg-two-factor/issues/87. + remove_action( 'show_user_profile', array( 'Two_Factor_Core', 'user_two_factor_options' ) ); remove_action( 'edit_user_profile', array( 'Two_Factor_Core', 'user_two_factor_options' ) ); remove_action( 'personal_options_update', array( 'Two_Factor_Core', 'user_two_factor_options_update' ) ); remove_action( 'edit_user_profile_update', array( 'Two_Factor_Core', 'user_two_factor_options_update' ) ); + */ add_action( 'bbp_user_edit_account', __NAMESPACE__ . '\render_custom_ui' ); } diff --git a/tests/bootstrap.php b/tests/bootstrap.php index 1755beb7..beb2300f 100644 --- a/tests/bootstrap.php +++ b/tests/bootstrap.php @@ -40,6 +40,7 @@ function _manually_load_plugin() { // Mimic w.org capes.php. $GLOBALS['super_admins'] = array(); + require_once dirname( __DIR__, 3 ) . '/mu-plugins/pub/mu-plugins/loader.php'; require dirname( __DIR__, 2 ) . '/two-factor/two-factor.php'; require dirname( __DIR__ ) . '/wporg-two-factor.php'; } diff --git a/tests/providers/test-class-two-factor-webauthn.php b/tests/providers/test-class-two-factor-webauthn.php new file mode 100644 index 00000000..16ae22b5 --- /dev/null +++ b/tests/providers/test-class-two-factor-webauthn.php @@ -0,0 +1,200 @@ +provider = Two_Factor_WebAuthn::get_instance(); + } + + /** + * Clean up after tests. + * + * @see WP_UnitTestCase::tearDown() + */ + public function tearDown(): void { + unset( $this->provider ); + + parent::tearDown(); + } + + /** + * Verify an instance exists. + * + * @covers Two_Factor_Totp::get_instance + */ + public function test_get_instance() { + $this->assertNotNull( $this->provider->get_instance() ); + } + + /** + * Verify the label value. + * + * @covers Two_Factor_WebAuthn::get_label + */ + public function test_get_label() { + $this->assertStringContainsString( 'Web Authentication (FIDO2)', $this->provider->get_label() ); + } + + /** + * Verify appi id is a valid hostname + * + * @covers Two_Factor_WebAuthn::get_app_id + */ + public function test_get_app_id() { + + $app_id = $this->provider->get_app_id(); + + // whether this is a valid hostname + $this->assertIsString( filter_var( $app_id, FILTER_VALIDATE_DOMAIN, FILTER_NULL_ON_FAILURE ) ); + + // the key is part of the current wp hostname + $this->assertStringContainsString( $app_id, get_option('home') ); + + } + + /** + * @covers Two_Factor_WebAuthn::validate_authentication + */ + public function test_validate_authentication() { + + $user_id = $this->factory->user->create(); + $user = new WP_User( $user_id ); + + $key = unserialize( $this->serialized_key ); + + $key_store = WebAuthnKeyStore::instance(); + + add_user_meta( $user_id, '_two_factor_enabled_providers', array( 'Two_Factor_WebAuthn' ) ); + add_user_meta( $user_id, '_two_factor_provider', 'Two_Factor_WebAuthn' ); + + $key_store->save_key( $user_id, $key, $key->md5id ); + + // test non-json response + $_POST['webauthn_response'] = '-- garbage --'; + + $result = $this->provider->validate_authentication( $user ); + $this->assertFalse( $result ); + + + // test successful authentication + // keys are domain specific. We are testing actual keys, so we can't simply use a dummy host here + $webauthn = new WebAuthnHandler( 'mu.wordpress.local' ); + $result = $webauthn->authenticate( json_decode( $this->authentication_payload ), $key_store->get_keys( $user_id ) ); + // craft a request, try to verify + $this->assertIsObject( $result ); // dummy + + + // test key deletion + $key_store->delete_key( $user_id, $key->md5id ); + $result = $key_store->find_key( $user_id, $key->md5id ); + $this->assertFalse( $result ); + + $result = $webauthn->authenticate( json_decode( $this->authentication_payload ), $key_store->get_keys( $user_id ) ); + // craft a request, try to verify + $this->assertFalse( $result ); // dummy + + } + + /** + * @covers Two_Factor_WebAuthn::ajax_register + */ + public function test_register() { + add_filter( 'wp_die_ajax_handler', function( $handler ) { return '__return_false'; } ); + add_filter( 'wp_ajax_handler', function() { return '__return_false'; } ); + + $user_id = $this->factory->user->create(); + $user = new WP_User( $user_id ); + + $webauthn = new WebAuthnHandler( 'mu.wordpress.local' ); + $key_store = WebAuthnKeyStore::instance(); + + $credential = json_decode( $this->registration_payload ); + + $key = $webauthn->register( $credential, '' ); + + $this->assertIsObject( $key ); + + /* translators: %s webauthn app id (domain) */ + $key->label = sprintf( esc_html__( 'New Device - %s', 'two-factor' ), $this->provider->get_app_id() ); + $key->md5id = md5( implode( '', array_map( 'chr', $key->id ) ) ); + $key->created = time(); + $key->last_used = false; + $key->tested = false; + + $meta_id = $key_store->save_key( $user_id, $key, $key->md5id ); + + $this->assertIsInt( $meta_id ); + + // save the same key again + $key->label = 'name was changed'; + $alternative_meta_id = $key_store->save_key( $user_id, $key, $key->md5id ); + + $this->assertEquals( $meta_id, $alternative_meta_id ); + + + // try to save the same key again + $new_meta_id = $key_store->create_key( $user_id, $key ); + + $this->assertFalse( $new_meta_id ); + + $keys = $key_store->get_keys( $user_id ); + $this->assertEquals( count( $keys ), 1 ); + + } + +} diff --git a/wporg-two-factor.php b/wporg-two-factor.php index 592ae22d..0a5c2cc3 100644 --- a/wporg-two-factor.php +++ b/wporg-two-factor.php @@ -40,14 +40,26 @@ function is_2fa_beta_tester() : bool { * Determine which providers should be available to users. */ function two_factor_providers( array $providers ) : array { + require_once __DIR__ . '/providers/webauthn/class-two-factor-webauthn.php'; + // Match the name => file path format of input var, but the path isn't needed. $desired_providers = array( - 'Two_Factor_WebAuthn' => '', 'Two_Factor_Totp' => '', 'Two_Factor_Backup_Codes' => '', ); + $providers = array_intersect_key( $providers, $desired_providers ); + + // This isn't ready or safe to run on production/staging right now, but is too big for a single PR. + // See https://github.com/WordPress/wporg-two-factor/issues/114 + if ( 'local' === wp_get_environment_type() ) { + // Make WebAuthn show up first in the wp-admin UI, since it's the most secure. + $providers = array_merge( + array( 'Two_Factor_WebAuthn' => __DIR__ . '/providers/webauthn/class-two-factor-webauthn.php' ), + $providers + ); + } - return array_intersect_key( $providers, $desired_providers ); + return $providers; } /**