├── .circleci └── config.yml ├── .gitignore ├── LICENSE.md ├── README.md ├── Samples ├── DNS.php └── HTTP.php ├── composer.json ├── phpstan-php8.2.neon ├── phpstan.neon ├── phpunit.xml.dist └── src ├── LE_ACME2 ├── AbstractKeyValuable.php ├── Account.php ├── Authorizer │ ├── AbstractAuthorizer.php │ ├── AbstractDNSWriter.php │ ├── DNS.php │ └── HTTP.php ├── Cache │ ├── AbstractKeyValuableCache.php │ ├── AccountResponse.php │ ├── DirectoryResponse.php │ ├── NewNonceResponse.php │ ├── OrderAuthorizationResponse.php │ └── OrderResponse.php ├── Connector │ ├── Connector.php │ └── RawResponse.php ├── Exception │ ├── AbstractException.php │ ├── AuthorizationInvalid.php │ ├── DNSAuthorizationInvalid.php │ ├── ExpiredAuthorization.php │ ├── HTTPAuthorizationInvalid.php │ ├── InvalidResponse.php │ ├── OpenSSLException.php │ ├── OrderStatusInvalid.php │ ├── RateLimitReached.php │ ├── ServiceUnavailable.php │ └── StatusInvalid.php ├── Order.php ├── Request │ ├── AbstractRequest.php │ ├── Account │ │ ├── AbstractLocation.php │ │ ├── ChangeKeys.php │ │ ├── Create.php │ │ ├── Deactivate.php │ │ ├── Get.php │ │ ├── GetData.php │ │ └── Update.php │ ├── Authorization │ │ ├── Get.php │ │ └── Start.php │ ├── GetDirectory.php │ ├── GetNewNonce.php │ └── Order │ │ ├── Create.php │ │ ├── Finalize.php │ │ ├── Get.php │ │ ├── GetCertificate.php │ │ └── RevokeCertificate.php ├── Response │ ├── AbstractResponse.php │ ├── Account │ │ ├── AbstractAccount.php │ │ ├── AbstractLocation.php │ │ ├── ChangeKeys.php │ │ ├── Create.php │ │ ├── Deactivate.php │ │ ├── Get.php │ │ ├── GetData.php │ │ └── Update.php │ ├── Authorization │ │ ├── AbstractAuthorization.php │ │ ├── Get.php │ │ ├── Start.php │ │ └── Struct │ │ │ ├── Challenge.php │ │ │ ├── ChallengeError.php │ │ │ ├── ChallengeErrorConstructorInterface.php │ │ │ └── Identifier.php │ ├── GetDirectory.php │ ├── GetNewNonce.php │ └── Order │ │ ├── AbstractOrder.php │ │ ├── Create.php │ │ ├── Finalize.php │ │ ├── Get.php │ │ ├── GetCertificate.php │ │ ├── RevokeCertificate.php │ │ └── Struct │ │ └── OrderError.php ├── SingletonTrait.php ├── Struct │ ├── CertificateBundle.php │ └── ChallengeAuthorizationKey.php └── Utilities │ ├── Base64.php │ ├── Certificate.php │ ├── ChallengeHTTP.php │ ├── Event.php │ ├── KeyGenerator.php │ ├── Logger.php │ └── RequestSigner.php └── LE_ACME2Tests ├── AbstractLeAcme2TestCase.php ├── AccountTest.php ├── Authorizer └── HTTPTest.php ├── Connector └── RawResponse.php ├── EnhancedTestCase.php ├── Exception ├── RateLimitReachedTest.php └── ServiceUnavailableTest.php ├── OrderTest.php ├── Response ├── Authorization │ └── GetTest.php ├── Order │ └── GetTest.php └── _JSONSamples │ ├── ChallengeError.json │ ├── OrderStatusInvalid.json │ └── OrderStatusInvalidHavingError.json ├── TestHelper.php └── Utilities └── CertificateTest.php /.circleci/config.yml: -------------------------------------------------------------------------------- 1 | # PHP CircleCI 2.0 configuration file 2 | # See: https://circleci.com/docs/2.0/language-php/ 3 | version: 2 4 | 5 | shared-code: &shared-code 6 | # Add steps to the job 7 | # See: https://circleci.com/docs/2.0/configuration-reference/#steps 8 | steps: 9 | - checkout 10 | - run: sudo apt update # PHP CircleCI 2.0 Configuration File# PHP CircleCI 2.0 Configuration File sudo apt install zlib1g-dev libsqlite3-dev 11 | #- run: sudo docker-php-ext-install zip 12 | 13 | # Download and cache dependencies 14 | - restore_cache: 15 | keys: 16 | # "composer.lock" can be used if it is committed to the repo 17 | - v1-dependencies-{{ checksum "composer.json" }} 18 | # fallback to using the latest cache if no exact match is found 19 | - v1-dependencies- 20 | 21 | - run: composer install -n --prefer-dist 22 | 23 | - save_cache: 24 | key: v1-dependencies-{{ checksum "composer.json" }} 25 | paths: 26 | - ./vendor 27 | # Node.JS disabled: not used 28 | #- restore_cache: 29 | # keys: 30 | # - node-v1-{{ checksum "package.json" }} 31 | # - node-v1- 32 | #- run: yarn install 33 | #- save_cache: 34 | # key: node-v1-{{ checksum "package.json" }} 35 | # paths: 36 | # - node_modules 37 | 38 | # run tests with phpunit or codecept 39 | - run: ./vendor/bin/phpunit 40 | 41 | # Define a job to be invoked later in a workflow. 42 | # See: https://circleci.com/docs/2.0/configuration-reference/#jobs 43 | jobs: 44 | "php-73-build": 45 | # Specify the execution environment. You can specify an image from Dockerhub or use one of our Convenience Images from CircleCI's Developer Hub. 46 | # See: https://circleci.com/docs/2.0/configuration-reference/#docker-machine-macos-windows-executor 47 | docker: 48 | # Specify the version you desire here 49 | - image: cimg/php:7.3 50 | 51 | <<: *shared-code 52 | "php-74-build": 53 | # Specify the execution environment. You can specify an image from Dockerhub or use one of our Convenience Images from CircleCI's Developer Hub. 54 | # See: https://circleci.com/docs/2.0/configuration-reference/#docker-machine-macos-windows-executor 55 | docker: 56 | # Specify the version you desire here 57 | - image: cimg/php:7.4 58 | 59 | <<: *shared-code 60 | "php-80-build": 61 | # Specify the execution environment. You can specify an image from Dockerhub or use one of our Convenience Images from CircleCI's Developer Hub. 62 | # See: https://circleci.com/docs/2.0/configuration-reference/#docker-machine-macos-windows-executor 63 | docker: 64 | # Specify the version you desire here 65 | - image: cimg/php:8.0 66 | 67 | <<: *shared-code 68 | "php-81-build": 69 | # Specify the execution environment. You can specify an image from Dockerhub or use one of our Convenience Images from CircleCI's Developer Hub. 70 | # See: https://circleci.com/docs/2.0/configuration-reference/#docker-machine-macos-windows-executor 71 | docker: 72 | # Specify the version you desire here 73 | - image: cimg/php:8.1 74 | 75 | <<: *shared-code 76 | "php-82-build": 77 | # Specify the execution environment. You can specify an image from Dockerhub or use one of our Convenience Images from CircleCI's Developer Hub. 78 | # See: https://circleci.com/docs/2.0/configuration-reference/#docker-machine-macos-windows-executor 79 | docker: 80 | # Specify the version you desire here 81 | - image: cimg/php:8.2.26 82 | 83 | <<: *shared-code 84 | "php-83-build": 85 | # Specify the execution environment. You can specify an image from Dockerhub or use one of our Convenience Images from CircleCI's Developer Hub. 86 | # See: https://circleci.com/docs/2.0/configuration-reference/#docker-machine-macos-windows-executor 87 | docker: 88 | # Specify the version you desire here 89 | - image: cimg/php:8.3.14 90 | 91 | <<: *shared-code 92 | 93 | workflows: 94 | version: 2 95 | php_82_jobs: 96 | jobs: 97 | - "php-82-build" 98 | php_83_jobs: 99 | jobs: 100 | - "php-83-build" 101 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | .idea 2 | phpunit.xml 3 | temp 4 | composer.lock 5 | vendor/ 6 | phpstan-php*.neon -------------------------------------------------------------------------------- /LICENSE.md: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Permission is hereby granted, free of charge, to any person obtaining a copy 4 | of this software and associated documentation files (the "Software"), to deal 5 | in the Software without restriction, including without limitation the rights 6 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 7 | copies of the Software, and to permit persons to whom the Software is 8 | furnished to do so, subject to the following conditions: 9 | 10 | The above copyright notice and this permission notice shall be included in all 11 | copies or substantial portions of the Software. 12 | 13 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 14 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 15 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 16 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 17 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 18 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 19 | SOFTWARE. 20 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # le-acme2-php [![CircleCI](https://circleci.com/gh/fbett/le-acme2-php/tree/master.svg?style=svg)](https://app.circleci.com/pipelines/github/fbett/le-acme2-php?branch=master) 2 | [![Scrutinizer Build Status](https://scrutinizer-ci.com/g/fbett/le-acme2-php/badges/build.png?b=master)](https://scrutinizer-ci.com/g/fbett/le-acme2-php/build-status/master) 3 | [![Scrutinizer Quality Score](https://scrutinizer-ci.com/g/fbett/le-acme2-php/badges/quality-score.png?b=master)](https://scrutinizer-ci.com/g/fbett/le-acme2-php/) 4 | [![Latest Stable Version](http://poser.pugx.org/fbett/le_acme2/v)](https://packagist.org/packages/fbett/le_acme2) 5 | [![License](http://poser.pugx.org/fbett/le_acme2/license)](https://packagist.org/packages/fbett/le_acme2) 6 | [![PHP Version Require](http://poser.pugx.org/fbett/le_acme2/require/php)](https://packagist.org/packages/fbett/le_acme2) 7 | 8 | LetsEncrypt client library for ACME v2 written in PHP. 9 | 10 | This library is inspired by [yourivw/LEClient](https://github.com/yourivw/LEClient), completely rewritten and enhanced with some new features: 11 | - Support for Composer autoload (including separated Namespaces) 12 | - Automatic renewal process 13 | - Managed HTTP authentication process 14 | - Response caching mechanism 15 | - Prevents blocking while waiting for server results 16 | - Optional set a preferred chain 17 | 18 | The aim of this client is to make an easy-to-use and integrated solution to create a LetsEncrypt-issued SSL/TLS certificate with PHP. 19 | 20 | You have the possibility to use the HTTP authentication: 21 | You need to be able to redirect specific requests (see below) 22 | 23 | You have also the possibility to use DNS authentication: 24 | You need to be able to set dynamic DNS configurations. 25 | 26 | Wildcard certificates can only be requested by using the dns authentication. 27 | 28 | ## Current version 29 | 30 | Tested with LetsEncrypt staging and production servers. 31 | 32 | ## Prerequisites 33 | 34 | The minimum required PHP version is 8.2. 35 | 36 | This client also depends on cURL and OpenSSL. 37 | 38 | ## Getting Started 39 | 40 | Install via composer: 41 | 42 | ``` 43 | composer require fbett/le_acme2 44 | ``` 45 | 46 | Also have a look at the [LetsEncrypt documentation](https://letsencrypt.org/docs/) for more information and documentation on LetsEncrypt and ACME. 47 | 48 | ## Example Integration 49 | 50 | - Create a working directory. 51 | Warning: This directory will also include private keys, so i suggest to place this directory somewhere not in the root document path of the web server. 52 | Additionally this directory should be protected to be read from other web server users. 53 | 54 | ``` 55 | mkdir /etc/ssl/le-storage/ 56 | chown root:root /etc/ssl/le-storage 57 | chmod 0600 /etc/ssl/le-storage 58 | ``` 59 | 60 | - (HTTP authorization only) Create a directory for the acme challenges. It must be reachable by http/https. 61 | 62 | ``` 63 | mkdir /var/www/acme-challenges 64 | ``` 65 | 66 | - (HTTP authorization only) Redirect specific requests to your acme-challenges directory 67 | 68 | Example apache virtual host configuration: 69 | 70 | ``` 71 | 72 | 73 | RewriteEngine On 74 | RewriteCond %{HTTPS} off 75 | RewriteRule \.well-known/acme-challenge/(.*)$ https://your-domain.com/path/to/acme-challenges/$1 [R=302,L] 76 | 77 | 78 | ``` 79 | 80 | - (DNS authorization only) Set the DNS configuration 81 | 82 | If `DNSWriter::write(...)` is called, set the DNS configuration like described in: 83 | 84 | [https://letsencrypt.org/docs/challenge-types/#dns-01-challenge](https://letsencrypt.org/docs/challenge-types/#dns-01-challenge) 85 | 86 | (By adding the digest as a TXT record for the subdomain '_acme-challenge'.) 87 | 88 | 89 | - Use the certificate bundle, if the certificate is issued: 90 | 91 | ``` 92 | if($order->isCertificateBundleAvailable()) { 93 | 94 | $bundle = $order->getCertificateBundle(); 95 | 96 | $pathToPrivateKey = $bundle->path . $bundle->private; 97 | $pathToCertificate = $bundle->path . $bundle->certificate; 98 | $pathToIntermediate = $bundle->path . $bundle->intermediate; 99 | 100 | $order->enableAutoRenewal(); // If the date of expiration is closer than thirty days, the order will automatically start the renewal process. 101 | } 102 | ``` 103 | 104 | If a certificate is renewed, the path will also change. 105 | 106 | My integrated workflow is the following: 107 | - User enables SSL to a specific domain in my control panel 108 | - The cronjob of this control panel will detect these changes and tries to create or get an order like in the sample. 109 | - The cronjob will fetch the information within the certificate bundle, if the certificate bundle is ready (mostly on the second run for challenge type HTTP and on the third run for challenge type DNS) 110 | - The cronjob will also build the Apache virtual host files and will restart the Apache2 service, if the new config file is different. 111 | 112 | Please take a look on the Samples for a full sample workflow. 113 | 114 | ## License 115 | 116 | This project is licensed under the MIT License - see the [LICENSE.md](LICENSE.md) file for details. 117 | -------------------------------------------------------------------------------- /Samples/DNS.php: -------------------------------------------------------------------------------- 1 | useStagingServer(true); 33 | \LE_ACME2\Utilities\Logger::getInstance()->setDesiredLevel(\LE_ACME2\Utilities\Logger::LEVEL_INFO); 34 | 35 | /** 36 | * OPTIONAL CONFIG: Delay connection response to prevent bleaching rate limits 37 | * 38 | * \LE_ACME2\Connector\Connector::getInstance()->delayResponse(400); 39 | */ 40 | 41 | /** 42 | * OPTIONAL CONFIG: Enable feature: OCSP Must Staple 43 | * 44 | * \LE_ACME2\Utilities\Certificate::enableFeatureOCSPMustStaple(); 45 | */ 46 | 47 | /** 48 | * OPTIONAL CONFIG: Set preferred chain 49 | * It is not reasonable usable in/after 2022, but we keep the method for future situations 50 | * 51 | * \LE_ACME2\Order::setPreferredChain(\LE_ACME2\Order::IDENTRUST_ISSUER_CN); 52 | */ 53 | 54 | /** 55 | * OPTIONAL CONFIG: Event subscriber 56 | * 57 | * \LE_ACME2\Utilities\Event::getInstance()->subscribe( 58 | \LE_ACME2\Utilities\Event::EVENT_CONNECTOR_WILL_REQUEST, 59 | function(string $event, array $payload = null) { 60 | // Do something, f.e. force to save the logs 61 | } 62 | ); 63 | */ 64 | 65 | /** 66 | * WORKFLOW START 67 | */ 68 | 69 | $account_email = 'test@example.org'; 70 | 71 | $account = !\LE_ACME2\Account::exists($account_email) ? 72 | \LE_ACME2\Account::create($account_email) : 73 | \LE_ACME2\Account::get($account_email); 74 | 75 | /** 76 | * OPTIONAL: Update email address 77 | * 78 | * $account->update('new-test@example.org'); 79 | */ 80 | 81 | /** 82 | * OPTIONAL: Deactivate account 83 | * Warning: It is not possible to reactivate an account. 84 | * 85 | * $account->deactivate(); 86 | */ 87 | 88 | $subjects = [ 89 | 'example.org', // First item will be set as common name on the certificate 90 | 'www.example.org' 91 | ]; 92 | 93 | if(!\LE_ACME2\Order::exists($account, $subjects)) { 94 | 95 | // Do some pre-checks, f.e. external dns checks - not required 96 | 97 | $order = \LE_ACME2\Order::create($account, $subjects); 98 | } else { 99 | $order = \LE_ACME2\Order::get($account, $subjects); 100 | } 101 | 102 | /** 103 | * OPTIONAL: Clear current order (in case to restart on status "invalid") 104 | * Already received certificate bundles will not be affected 105 | * 106 | * $order->clear(); 107 | */ 108 | 109 | if($order->shouldStartAuthorization(\LE_ACME2\Order::CHALLENGE_TYPE_DNS)) { 110 | // Do some pre-checks, f.e. external dns checks - not required 111 | } 112 | 113 | if($order->authorize(\LE_ACME2\Order::CHALLENGE_TYPE_DNS)) { 114 | $order->finalize(); 115 | } 116 | 117 | if($order->isCertificateBundleAvailable()) { 118 | 119 | $bundle = $order->getCertificateBundle(); 120 | $order->enableAutoRenewal(); 121 | 122 | /** 123 | * OPTIONAL: Revoke certificate 124 | * 125 | * $order->revokeCertificate($reason = 0); 126 | */ 127 | } -------------------------------------------------------------------------------- /Samples/HTTP.php: -------------------------------------------------------------------------------- 1 | useStagingServer(true); 15 | \LE_ACME2\Utilities\Logger::getInstance()->setDesiredLevel(\LE_ACME2\Utilities\Logger::LEVEL_INFO); 16 | 17 | /** 18 | * OPTIONAL CONFIG: Delay connection response to prevent bleaching rate limits 19 | * 20 | * \LE_ACME2\Connector\Connector::getInstance()->delayResponse(400); 21 | */ 22 | 23 | /** 24 | * OPTIONAL CONFIG: Enable feature: OCSP Must Staple 25 | * 26 | * \LE_ACME2\Utilities\Certificate::enableFeatureOCSPMustStaple(); 27 | */ 28 | 29 | /** 30 | * OPTIONAL CONFIG: Set preferred chain 31 | * It is not reasonable usable in/after 2022, but we keep the method for future situations 32 | * 33 | * \LE_ACME2\Order::setPreferredChain(\LE_ACME2\Order::IDENTRUST_ISSUER_CN); 34 | */ 35 | 36 | /** 37 | * OPTIONAL CONFIG: Event subscriber 38 | * 39 | * \LE_ACME2\Utilities\Event::getInstance()->subscribe( 40 | \LE_ACME2\Utilities\Event::EVENT_CONNECTOR_WILL_REQUEST, 41 | function(string $event, array $payload = null) { 42 | // Do something, f.e. force to save the logs 43 | } 44 | ); 45 | */ 46 | 47 | /** 48 | * WORKFLOW START 49 | */ 50 | 51 | $account_email = 'test@example.org'; 52 | 53 | $account = !\LE_ACME2\Account::exists($account_email) ? 54 | \LE_ACME2\Account::create($account_email) : 55 | \LE_ACME2\Account::get($account_email); 56 | 57 | /** 58 | * OPTIONAL: Update email address 59 | * 60 | * $account->update('new-test@example.org'); 61 | */ 62 | 63 | /** 64 | * OPTIONAL: Deactivate account 65 | * Warning: It is not possible to reactivate an account. 66 | * 67 | * $account->deactivate(); 68 | */ 69 | 70 | $subjects = [ 71 | 'example.org', // First item will be set as common name on the certificate 72 | 'www.example.org' 73 | ]; 74 | 75 | if(!\LE_ACME2\Order::exists($account, $subjects)) { 76 | 77 | // Do some pre-checks, f.e. external dns checks - not required 78 | 79 | $order = \LE_ACME2\Order::create($account, $subjects); 80 | } else { 81 | $order = \LE_ACME2\Order::get($account, $subjects); 82 | } 83 | 84 | /** 85 | * OPTIONAL: Clear current order (in case to restart on status "invalid") 86 | * Already received certificate bundles will not be affected 87 | * 88 | * $order->clear(); 89 | */ 90 | 91 | if($order->shouldStartAuthorization(\LE_ACME2\Order::CHALLENGE_TYPE_HTTP)) { 92 | // Do some pre-checks, f.e. external dns checks - not required 93 | 94 | // Example test: 95 | foreach($subjects as $subject) { 96 | try { 97 | $response = \LE_ACME2\Utilities\ChallengeHTTP::fetch($subject, \LE_ACME2\Authorizer\HTTP::TEST_TOKEN); 98 | if($response != \LE_ACME2\Authorizer\HTTP::TEST_CHALLENGE) { 99 | die('Invalid response: ' . var_export([ 100 | 'Expected:' => \LE_ACME2\Authorizer\HTTP::TEST_CHALLENGE, 101 | 'Response:' => $response, 102 | ])); 103 | } 104 | } catch(\LE_ACME2\Exception\HTTPAuthorizationInvalid $e) { 105 | die('Exception thrown while validating HTTP authorization: ' . $e->getMessage()); 106 | } 107 | } 108 | 109 | } 110 | 111 | if($order->authorize(\LE_ACME2\Order::CHALLENGE_TYPE_HTTP)) { 112 | $order->finalize(); 113 | } 114 | 115 | if($order->isCertificateBundleAvailable()) { 116 | 117 | $bundle = $order->getCertificateBundle(); 118 | $order->enableAutoRenewal(); 119 | 120 | /** 121 | * OPTIONAL: Revoke certificate 122 | * 123 | * $order->revokeCertificate($reason = 0); 124 | */ 125 | } -------------------------------------------------------------------------------- /composer.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "fbett/le_acme2", 3 | "description": "Letsencrypt PHP ACME v2 client", 4 | "homepage": "https://github.com/fbett/le-acme2-php", 5 | "version": "1.8.4", 6 | "license": "MIT", 7 | "authors": [ 8 | { 9 | "name": "Fabian Bett", 10 | "homepage": "https://www.bett-ingenieure.de", 11 | "role": "Developer" 12 | } 13 | ], 14 | "autoload": { 15 | "psr-0": { 16 | "LE_ACME2": "src/" 17 | } 18 | }, 19 | "require": { 20 | "php": ">=8.2", 21 | "ext-curl": "*", 22 | "ext-openssl": "*", 23 | "ext-json": "*" 24 | }, 25 | "require-dev": { 26 | "phpunit/phpunit": "10.5.5", 27 | "psr/log": "^3.0" 28 | } 29 | } 30 | -------------------------------------------------------------------------------- /phpstan-php8.2.neon: -------------------------------------------------------------------------------- 1 | /Users/fabian/Syncthing/PHPStorm/BI.Modules/tester/phpstan-php8.2.neon -------------------------------------------------------------------------------- /phpstan.neon: -------------------------------------------------------------------------------- 1 | parameters: 2 | level: 3 3 | tmpDir: temp/phpstan/default 4 | ignoreErrors: 5 | - '/^Deprecated in PHP 8.0: Required parameter .* follows optional parameter .*\.$/' 6 | 7 | paths: 8 | - src 9 | bootstrapFiles: 10 | reportUnmatchedIgnoredErrors: false 11 | -------------------------------------------------------------------------------- /phpunit.xml.dist: -------------------------------------------------------------------------------- 1 | 2 | 14 | 15 | 16 | ./src/LE_ACME2 17 | 18 | 19 | 20 | 21 | ./src/LE_ACME2Tests 22 | 23 | 24 | 25 | -------------------------------------------------------------------------------- /src/LE_ACME2/AbstractKeyValuable.php: -------------------------------------------------------------------------------- 1 | _identifier . $appendix . DIRECTORY_SEPARATOR; 36 | } 37 | 38 | public function getKeyDirectoryPath() : string { 39 | 40 | return $this->_getKeyDirectoryPath(''); 41 | } 42 | 43 | protected function _initKeyDirectory(string $keyType = self::KEY_TYPE_RSA, bool $ignoreIfKeysExist = false) { 44 | 45 | if(!file_exists($this->getKeyDirectoryPath())) { 46 | 47 | mkdir($this->getKeyDirectoryPath()); 48 | } 49 | 50 | if(!$ignoreIfKeysExist && ( 51 | file_exists($this->getKeyDirectoryPath() . 'private.pem') || 52 | file_exists($this->getKeyDirectoryPath() . 'public.pem') 53 | ) 54 | ) { 55 | 56 | throw new \RuntimeException( 57 | 'Keys exist already. Exists the ' . get_class($this) . ' already?' . PHP_EOL . 58 | 'Path: ' . $this->getKeyDirectoryPath() 59 | ); 60 | } 61 | 62 | if($keyType == self::KEY_TYPE_RSA) { 63 | 64 | Utilities\KeyGenerator::RSA( 65 | $this->getKeyDirectoryPath(), 66 | 'private.pem', 67 | 'public.pem' 68 | ); 69 | } else if($keyType == self::KEY_TYPE_EC) { 70 | 71 | Utilities\KeyGenerator::EC( 72 | $this->getKeyDirectoryPath(), 73 | 'private.pem', 74 | 'public.pem' 75 | ); 76 | } else { 77 | 78 | throw new \RuntimeException('Key type "' . $keyType . '" not supported.'); 79 | } 80 | } 81 | 82 | protected function _clearKeyDirectory() { 83 | 84 | if(file_exists($this->getKeyDirectoryPath() . 'private.pem')) { 85 | unlink($this->getKeyDirectoryPath() . 'private.pem'); 86 | } 87 | 88 | if(file_exists($this->getKeyDirectoryPath() . 'public.pem')) { 89 | unlink($this->getKeyDirectoryPath() . 'public.pem'); 90 | } 91 | } 92 | 93 | protected function _getAccountIdentifier(Account $account) : string { 94 | 95 | $staging = Connector::getInstance()->isUsingStagingServer(); 96 | 97 | return 'account_' . ($staging ? 'staging_' : 'live_') . $account->getEmail(); 98 | } 99 | } -------------------------------------------------------------------------------- /src/LE_ACME2/Account.php: -------------------------------------------------------------------------------- 1 | _setEmail($email); 17 | 18 | Utilities\Logger::getInstance()->add( 19 | Utilities\Logger::LEVEL_DEBUG, 20 | static::class . '::' . __FUNCTION__ . 21 | ' email: "' . $email . '" ' . 22 | ' path: ' . $this->getKeyDirectoryPath() 23 | ); 24 | } 25 | 26 | private function _setEmail(string $email) { 27 | 28 | $this->_email = $email; 29 | $this->_identifier = $this->_getAccountIdentifier($this); 30 | } 31 | 32 | public function getEmail() : string { 33 | 34 | return $this->_email; 35 | } 36 | 37 | /** 38 | * @throws Exception\AbstractException 39 | */ 40 | public static function create(string $email) : Account { 41 | 42 | Utilities\Logger::getInstance()->add( 43 | Utilities\Logger::LEVEL_INFO, 44 | static::class . '::' . __FUNCTION__ . ' email: "' . $email . '"' 45 | ); 46 | 47 | $account = new self($email); 48 | $account->_initKeyDirectory(); 49 | 50 | $request = new Request\Account\Create($account); 51 | 52 | try { 53 | $response = $request->getResponse(); 54 | 55 | Cache\AccountResponse::getInstance()->set($account, $response); 56 | 57 | return $account; 58 | 59 | } catch(Exception\AbstractException $e) { 60 | 61 | $account->_clearKeyDirectory(); 62 | throw $e; 63 | } 64 | } 65 | 66 | public static function exists(string $email) : bool { 67 | 68 | $account = new self($email); 69 | 70 | return file_exists($account->getKeyDirectoryPath()) && 71 | file_exists($account->getKeyDirectoryPath() . 'private.pem') && 72 | file_exists($account->getKeyDirectoryPath() . 'public.pem'); 73 | } 74 | 75 | public static function get(string $email) : Account { 76 | 77 | Utilities\Logger::getInstance()->add( 78 | Utilities\Logger::LEVEL_INFO, 79 | static::class . '::' . __FUNCTION__ . ' email: "' . $email . '"' 80 | ); 81 | 82 | $account = new self($email); 83 | 84 | if(!self::exists($email)) 85 | throw new \RuntimeException('Keys not found - does this account exist?'); 86 | 87 | return $account; 88 | } 89 | 90 | /** 91 | * @throws Exception\InvalidResponse 92 | * @throws Exception\RateLimitReached 93 | * @throws Exception\ServiceUnavailable 94 | */ 95 | public function getData() : Response\Account\GetData { 96 | 97 | $request = new Request\Account\GetData($this); 98 | return $request->getResponse(); 99 | } 100 | 101 | /** 102 | * @throws Exception\RateLimitReached 103 | * @throws Exception\ServiceUnavailable 104 | */ 105 | public function update(string $email) : bool { 106 | 107 | $request = new Request\Account\Update($this, $email); 108 | 109 | try { 110 | /* $response = */ $request->getResponse(); 111 | 112 | $previousKeyDirectoryPath = $this->getKeyDirectoryPath(); 113 | 114 | $this->_setEmail($email); 115 | 116 | if($previousKeyDirectoryPath != $this->getKeyDirectoryPath()) 117 | rename($previousKeyDirectoryPath, $this->getKeyDirectoryPath()); 118 | 119 | return true; 120 | 121 | } catch(Exception\InvalidResponse $e) { 122 | return false; 123 | } 124 | } 125 | 126 | /** 127 | * @throws Exception\RateLimitReached 128 | * @throws Exception\ServiceUnavailable 129 | */ 130 | public function changeKeys() : bool { 131 | 132 | Utilities\KeyGenerator::RSA($this->getKeyDirectoryPath(), 'private-replacement.pem', 'public-replacement.pem'); 133 | 134 | $request = new Request\Account\ChangeKeys($this); 135 | try { 136 | /* $response = */ $request->getResponse(); 137 | 138 | unlink($this->getKeyDirectoryPath() . 'private.pem'); 139 | unlink($this->getKeyDirectoryPath() . 'public.pem'); 140 | rename($this->getKeyDirectoryPath() . 'private-replacement.pem', $this->getKeyDirectoryPath() . 'private.pem'); 141 | rename($this->getKeyDirectoryPath() . 'public-replacement.pem', $this->getKeyDirectoryPath() . 'public.pem'); 142 | return true; 143 | 144 | } catch(Exception\InvalidResponse $e) { 145 | 146 | return false; 147 | } 148 | } 149 | 150 | /** 151 | * @throws Exception\RateLimitReached 152 | * @throws Exception\ServiceUnavailable 153 | */ 154 | public function deactivate() : bool { 155 | 156 | $request = new Request\Account\Deactivate($this); 157 | 158 | try { 159 | /* $response = */ $request->getResponse(); 160 | 161 | return true; 162 | 163 | } catch(Exception\InvalidResponse $e) { 164 | return false; 165 | } 166 | } 167 | } -------------------------------------------------------------------------------- /src/LE_ACME2/Authorizer/AbstractAuthorizer.php: -------------------------------------------------------------------------------- 1 | _account = $account; 29 | $this->_order = $order; 30 | 31 | $this->_fetchAuthorizationResponses(); 32 | } 33 | 34 | /** @var Response\Authorization\Get[] $_authorizationResponses */ 35 | protected $_authorizationResponses = []; 36 | 37 | /** 38 | * @throws Exception\InvalidResponse 39 | * @throws Exception\RateLimitReached 40 | * @throws Exception\ExpiredAuthorization 41 | */ 42 | protected function _fetchAuthorizationResponses() { 43 | 44 | if(!file_exists($this->_order->getKeyDirectoryPath() . 'private.pem')) { 45 | 46 | Utilities\Logger::getInstance()->add( 47 | Utilities\Logger::LEVEL_DEBUG, 48 | static::class . '::' . __FUNCTION__ . ' result suppressed (Order has finished already)', 49 | ); 50 | 51 | return; 52 | } 53 | 54 | $orderResponse = Cache\OrderResponse::getInstance()->get($this->_order); 55 | 56 | foreach($orderResponse->getAuthorizations() as $authorization) { 57 | 58 | $this->_authorizationResponses[] = Cache\OrderAuthorizationResponse::getInstance() 59 | ->get($this->_order, $authorization, $this->_getChallengeType()); 60 | } 61 | } 62 | 63 | protected function _hasValidAuthorizationResponses() : bool { 64 | 65 | return count($this->_authorizationResponses) > 0; 66 | } 67 | 68 | public function shouldStartAuthorization() : bool { 69 | 70 | foreach($this->_authorizationResponses as $response) { 71 | 72 | $challenge = $response->getChallenge($this->_getChallengeType()); 73 | if($challenge && $challenge->status == Response\Authorization\Struct\Challenge::STATUS_PENDING) { 74 | 75 | Utilities\Logger::getInstance()->add( 76 | Utilities\Logger::LEVEL_DEBUG, 77 | static::class . '::' . __FUNCTION__ . ' "Pending challenge found', 78 | [get_class($challenge) => $challenge] 79 | ); 80 | 81 | return true; 82 | } 83 | } 84 | return false; 85 | } 86 | 87 | abstract protected function _getChallengeType() : string; 88 | 89 | private $_progressed = false; 90 | 91 | /** 92 | * @throws Exception\AuthorizationInvalid 93 | * @throws Exception\InvalidResponse 94 | * @throws Exception\RateLimitReached 95 | * @throws Exception\ExpiredAuthorization 96 | */ 97 | public function progress() { 98 | 99 | if($this->_progressed) { 100 | return; 101 | } 102 | 103 | $this->_progressed = true; 104 | 105 | if(!$this->_hasValidAuthorizationResponses()) 106 | return; 107 | 108 | $existsNotValidChallenges = false; 109 | 110 | foreach($this->_authorizationResponses as $authorizationResponse) { 111 | 112 | $challenge = $authorizationResponse->getChallenge($this->_getChallengeType()); 113 | 114 | if($challenge && $this->_existsNotValidChallenges($challenge, $authorizationResponse)) { 115 | $existsNotValidChallenges = true; 116 | } 117 | } 118 | 119 | $this->_finished = !$existsNotValidChallenges; 120 | } 121 | 122 | /** 123 | * @throws Exception\AuthorizationInvalid 124 | */ 125 | protected function _existsNotValidChallenges(Response\Authorization\Struct\Challenge $challenge, 126 | Response\Authorization\Get $authorizationResponse 127 | ) : bool { 128 | 129 | if($challenge->status == Response\Authorization\Struct\Challenge::STATUS_PENDING) { 130 | 131 | Utilities\Logger::getInstance()->add( 132 | Utilities\Logger::LEVEL_DEBUG, 133 | static::class . '::' . __FUNCTION__ . ' "Non valid challenge found', 134 | [get_class($challenge) => $challenge] 135 | ); 136 | 137 | return true; 138 | } 139 | else if($challenge->status == Response\Authorization\Struct\Challenge::STATUS_PROGRESSING) { 140 | 141 | // Should come back later 142 | return true; 143 | } 144 | else if($challenge->status == Response\Authorization\Struct\Challenge::STATUS_VALID) { 145 | 146 | } 147 | else if($challenge->status == Response\Authorization\Struct\Challenge::STATUS_INVALID) { 148 | throw new Exception\AuthorizationInvalid( 149 | 'Received status "' . Response\Authorization\Struct\Challenge::STATUS_INVALID . '" while challenge should be verified' 150 | ); 151 | } 152 | else { 153 | 154 | throw new \RuntimeException('Challenge status "' . $challenge->status . '" is not implemented'); 155 | } 156 | 157 | return false; 158 | } 159 | 160 | protected $_finished = false; 161 | 162 | public function hasFinished() : bool { 163 | 164 | Utilities\Logger::getInstance()->add( 165 | Utilities\Logger::LEVEL_DEBUG, 166 | get_called_class() . '::' . __FUNCTION__, 167 | ['finished' => $this->_finished] 168 | ); 169 | 170 | return $this->_finished; 171 | } 172 | } 173 | 174 | -------------------------------------------------------------------------------- /src/LE_ACME2/Authorizer/AbstractDNSWriter.php: -------------------------------------------------------------------------------- 1 | status == Response\Authorization\Struct\Challenge::STATUS_PENDING) { 39 | 40 | if(self::$_dnsWriter === null) { 41 | throw new \RuntimeException('DNS writer is not set'); 42 | } 43 | 44 | if( self::$_dnsWriter->write( 45 | $this->_order, 46 | $authorizationResponse->getIdentifier()->value, 47 | (new ChallengeAuthorizationKey($this->_account))->getEncoded($challenge->token) 48 | ) 49 | ) { 50 | $request = new Request\Authorization\Start($this->_account, $this->_order, $challenge); 51 | /* $response = */ $request->getResponse(); 52 | } else { 53 | 54 | Utilities\Logger::getInstance()->add(Utilities\Logger::LEVEL_INFO, 'Pending challenge deferred'); 55 | } 56 | } 57 | 58 | if($challenge->status == Response\Authorization\Struct\Challenge::STATUS_INVALID) { 59 | throw new Exception\DNSAuthorizationInvalid( 60 | 'Received status "' . Response\Authorization\Struct\Challenge::STATUS_INVALID . '" while challenge should be verified' 61 | ); 62 | } 63 | 64 | return parent::_existsNotValidChallenges($challenge, $authorizationResponse); 65 | } 66 | } -------------------------------------------------------------------------------- /src/LE_ACME2/Authorizer/HTTP.php: -------------------------------------------------------------------------------- 1 | add( 54 | Utilities\Logger::LEVEL_DEBUG, 55 | 'Challenge "' . $challenge->token . '" has status:' . $challenge->status 56 | ); 57 | 58 | if($challenge->status == Response\Authorization\Struct\Challenge::STATUS_PENDING) { 59 | 60 | $this->_writeToFile($challenge); 61 | if($this->_validateFile($authorizationResponse->getIdentifier()->value, $challenge)) { 62 | 63 | $request = new Request\Authorization\Start($this->_account, $this->_order, $challenge); 64 | /* $response = */ $request->getResponse(); 65 | } else { 66 | 67 | Utilities\Logger::getInstance()->add(Utilities\Logger::LEVEL_INFO, 'Could not validate HTTP Authorization file'); 68 | } 69 | } 70 | 71 | if($challenge->status == Response\Authorization\Struct\Challenge::STATUS_INVALID) { 72 | throw new Exception\HTTPAuthorizationInvalid( 73 | 'Received status "' . Response\Authorization\Struct\Challenge::STATUS_INVALID . '" while challenge should be verified' 74 | ); 75 | } 76 | 77 | return parent::_existsNotValidChallenges($challenge, $authorizationResponse); 78 | } 79 | 80 | private function _writeToFile(Response\Authorization\Struct\Challenge $challenge) : void { 81 | 82 | file_put_contents( 83 | self::$_directoryPath . $challenge->token, 84 | (new ChallengeAuthorizationKey($this->_account))->get($challenge->token) 85 | ); 86 | } 87 | 88 | /** 89 | * @throws Exception\HTTPAuthorizationInvalid 90 | */ 91 | private function _validateFile(string $domain, Response\Authorization\Struct\Challenge $challenge) : bool { 92 | 93 | $challengeAuthorizationKey = new ChallengeAuthorizationKey($this->_account); 94 | 95 | $expectedResponse = $challengeAuthorizationKey->get($challenge->token); 96 | $response = Utilities\ChallengeHTTP::fetch($domain, $challenge->token); 97 | 98 | if($response != $expectedResponse) { 99 | 100 | throw new Exception\HTTPAuthorizationInvalid( 101 | 'HTTP challenge for "' . $domain . '"": ' . 102 | $domain . '/.well-known/acme-challenge/' . $challenge->token . 103 | ' tested, found invalid.' . PHP_EOL . 104 | '- Expected: ' . var_export($expectedResponse, true) . PHP_EOL . 105 | '- Response: ' . var_export($response, true) 106 | ); 107 | } 108 | return true; 109 | } 110 | } -------------------------------------------------------------------------------- /src/LE_ACME2/Cache/AbstractKeyValuableCache.php: -------------------------------------------------------------------------------- 1 | getKeyDirectoryPath(); 12 | } 13 | } -------------------------------------------------------------------------------- /src/LE_ACME2/Cache/AccountResponse.php: -------------------------------------------------------------------------------- 1 | _getObjectIdentifier($account); 29 | 30 | if(array_key_exists($accountIdentifier, $this->_responses)) { 31 | return $this->_responses[ $accountIdentifier ]; 32 | } 33 | $this->_responses[ $accountIdentifier ] = null; 34 | 35 | $cacheFile = $account->getKeyDirectoryPath() . self::_FILE; 36 | $deprecatedCacheFile = $account->getKeyDirectoryPath() . self::_DEPRECATED_FILE; 37 | 38 | if(file_exists($deprecatedCacheFile) && !file_exists($cacheFile)) { 39 | rename($deprecatedCacheFile, $cacheFile); 40 | } 41 | 42 | if(file_exists($cacheFile) && filemtime($cacheFile) > strtotime('-7 days')) { 43 | 44 | $rawResponse = Connector\RawResponse::getFromString(file_get_contents($cacheFile)); 45 | 46 | $response = new Response\Account\Create($rawResponse); 47 | 48 | $this->_responses[ $accountIdentifier ] = $response; 49 | 50 | Utilities\Logger::getInstance()->add( 51 | Utilities\Logger::LEVEL_DEBUG, 52 | static::class . '::' . __FUNCTION__ . ' response from cache' 53 | ); 54 | 55 | return $response; 56 | } 57 | 58 | $request = new Request\Account\Get($account); 59 | $response = $request->getResponse(); 60 | 61 | $this->set($account, $response); 62 | 63 | return $response; 64 | } 65 | 66 | public function set(Account $account, Response\Account\AbstractAccount $response = null) : void { 67 | 68 | $accountIdentifier = $this->_getObjectIdentifier($account); 69 | 70 | $filePath = $account->getKeyDirectoryPath() . self::_FILE; 71 | 72 | if($response === null) { 73 | 74 | unset($this->_responses[$accountIdentifier]); 75 | 76 | if(file_exists($filePath)) { 77 | unlink($filePath); 78 | } 79 | 80 | return; 81 | } 82 | 83 | $this->_responses[$accountIdentifier] = $response; 84 | file_put_contents($filePath, $response->getRaw()->toString()); 85 | } 86 | } -------------------------------------------------------------------------------- /src/LE_ACME2/Cache/DirectoryResponse.php: -------------------------------------------------------------------------------- 1 | _index, $this->_responses)) { 32 | return $this->_responses[$this->_index]; 33 | } 34 | $this->_responses[$this->_index] = null; 35 | 36 | $cacheFile = Account::getCommonKeyDirectoryPath() . self::_FILE; 37 | 38 | if(file_exists($cacheFile) && filemtime($cacheFile) > strtotime('-2 days')) { 39 | 40 | $rawResponse = Connector\RawResponse::getFromString(file_get_contents($cacheFile)); 41 | 42 | try { 43 | return $this->_responses[$this->_index] = new Response\GetDirectory($rawResponse); 44 | 45 | } catch(Exception\AbstractException $e) { 46 | unlink($cacheFile); 47 | } 48 | } 49 | 50 | $request = new Request\GetDirectory(); 51 | $response = $request->getResponse(); 52 | $this->set($response); 53 | 54 | return $response; 55 | } 56 | 57 | public function set(Response\GetDirectory $response) : void { 58 | 59 | $cacheFile = Account::getCommonKeyDirectoryPath() . self::_FILE; 60 | 61 | $this->_responses[$this->_index] = $response; 62 | file_put_contents($cacheFile, $response->getRaw()->toString()); 63 | } 64 | } -------------------------------------------------------------------------------- /src/LE_ACME2/Cache/NewNonceResponse.php: -------------------------------------------------------------------------------- 1 | _index, $this->_responses)) { 27 | return $this->_responses[$this->_index]; 28 | } 29 | $this->_responses[$this->_index] = null; 30 | 31 | $request = new Request\GetNewNonce(); 32 | $response = $request->getResponse(); 33 | $this->set($response); 34 | 35 | return $response; 36 | } 37 | 38 | public function set(Response\GetNewNonce $response) : void { 39 | $this->_responses[$this->_index] = $response; 40 | } 41 | } -------------------------------------------------------------------------------- /src/LE_ACME2/Cache/OrderAuthorizationResponse.php: -------------------------------------------------------------------------------- 1 | getKeyDirectoryPath() . self::_FILE_prefix . '-' . md5($authorizationUrl); 22 | } 23 | 24 | /** 25 | * @throws Exception\ExpiredAuthorization 26 | * @throws Exception\InvalidResponse 27 | * @throws Exception\RateLimitReached 28 | * @throws Exception\ServiceUnavailable 29 | */ 30 | public function get(Order $order, string $authorizationUrl, string $challengeType): Response\Authorization\Get { 31 | 32 | $accountIdentifier = $this->_getObjectIdentifier($order->getAccount()); 33 | $orderIdentifier = $this->_getObjectIdentifier($order); 34 | 35 | if(!isset($this->_responses[$accountIdentifier][$orderIdentifier])) { 36 | $this->_responses[$accountIdentifier][$orderIdentifier] = []; 37 | } 38 | 39 | if(array_key_exists($authorizationUrl, $this->_responses[$accountIdentifier][$orderIdentifier])) { 40 | return $this->_responses[ $accountIdentifier ][ $orderIdentifier ][ $authorizationUrl ]; 41 | } 42 | $this->_responses[ $accountIdentifier ][ $orderIdentifier ][ $authorizationUrl ] = null; 43 | 44 | $cacheFile = $this->_getCacheFilePath($order, $authorizationUrl); 45 | 46 | if(file_exists($cacheFile)) { 47 | 48 | $rawResponse = Connector\RawResponse::getFromString(file_get_contents($cacheFile)); 49 | 50 | $response = new Response\Authorization\Get($rawResponse); 51 | if( 52 | ($challenge = $response->getChallenge($challengeType)) && 53 | $challenge->status == Response\Authorization\Struct\Challenge::STATUS_PROGRESSING 54 | ) { 55 | 56 | Utilities\Logger::getInstance()->add( 57 | Utilities\Logger::LEVEL_DEBUG, 58 | static::class . '::' . __FUNCTION__ . ' (cache did not satisfy, status "' . $response->getStatus() . '")' 59 | ); 60 | 61 | $request = new Request\Authorization\Get($order->getAccount(), $authorizationUrl); 62 | $this->set($order, $authorizationUrl, $response = $request->getResponse()); 63 | return $response; 64 | } 65 | 66 | Utilities\Logger::getInstance()->add( 67 | Utilities\Logger::LEVEL_DEBUG, 68 | static::class . '::' . __FUNCTION__ . ' (from cache, status "' . $response->getStatus() . '")' 69 | ); 70 | 71 | $this->_responses[$accountIdentifier][$orderIdentifier][$authorizationUrl] = $response; 72 | 73 | return $response; 74 | } 75 | 76 | $request = new Request\Authorization\Get($order->getAccount(), $authorizationUrl); 77 | $this->set($order, $authorizationUrl, $response = $request->getResponse()); 78 | return $response; 79 | } 80 | 81 | public function set(Order $order, string $authorizationUrl, Response\Authorization\Get $response = null) : void { 82 | 83 | $accountIdentifier = $this->_getObjectIdentifier($order->getAccount()); 84 | $orderIdentifier = $this->_getObjectIdentifier($order); 85 | 86 | $filePath = $this->_getCacheFilePath($order, $authorizationUrl); 87 | 88 | if($response === null) { 89 | 90 | unset($this->_responses[$accountIdentifier][$orderIdentifier][$authorizationUrl]); 91 | 92 | if(file_exists($filePath)) { 93 | unlink($filePath); 94 | } 95 | 96 | return; 97 | } 98 | 99 | $this->_responses[$accountIdentifier][$orderIdentifier][$authorizationUrl] = $response; 100 | file_put_contents($filePath, $response->getRaw()->toString()); 101 | } 102 | 103 | /** 104 | * Clear the cache, when the next response could be different: 105 | * - Order has ended (certificate received) 106 | * - Authorization::Start 107 | * 108 | * @throws Exception\InvalidResponse 109 | * @throws Exception\RateLimitReached 110 | * @throws Exception\ServiceUnavailable 111 | */ 112 | public function clear(Order $order) : void { 113 | 114 | $orderResponse = OrderResponse::getInstance()->get($order); 115 | foreach($orderResponse->getAuthorizations() as $authorization) { 116 | $this->set($order, $authorization, null); 117 | } 118 | } 119 | } -------------------------------------------------------------------------------- /src/LE_ACME2/Cache/OrderResponse.php: -------------------------------------------------------------------------------- 1 | getKeyDirectoryPath() . self::_FILE; 24 | $deprecatedCacheFile = $order->getKeyDirectoryPath() . self::_DEPRECATED_FILE; 25 | 26 | return file_exists($cacheFile) || file_exists($deprecatedCacheFile); 27 | } 28 | 29 | /** 30 | * @throws Exception\InvalidResponse 31 | * @throws Exception\RateLimitReached 32 | * @throws Exception\ServiceUnavailable 33 | */ 34 | public function get(Order $order): Response\Order\AbstractOrder { 35 | 36 | $accountIdentifier = $this->_getObjectIdentifier($order->getAccount()); 37 | $orderIdentifier = $this->_getObjectIdentifier($order); 38 | 39 | if(!isset($this->_responses[$accountIdentifier])) { 40 | $this->_responses[$accountIdentifier] = []; 41 | } 42 | 43 | if(array_key_exists($orderIdentifier, $this->_responses[$accountIdentifier])) { 44 | return $this->_responses[ $accountIdentifier ][ $orderIdentifier ]; 45 | } 46 | $this->_responses[ $accountIdentifier ][ $orderIdentifier ] = null; 47 | 48 | $cacheFile = $order->getKeyDirectoryPath() . self::_FILE; 49 | $deprecatedCacheFile = $order->getKeyDirectoryPath() . self::_DEPRECATED_FILE; 50 | 51 | if(file_exists($deprecatedCacheFile) && !file_exists($cacheFile)) { 52 | rename($deprecatedCacheFile, $cacheFile); 53 | } 54 | 55 | if(file_exists($cacheFile)) { 56 | 57 | $rawResponse = Connector\RawResponse::getFromString(file_get_contents($cacheFile)); 58 | 59 | $response = new Response\Order\Create($rawResponse); 60 | 61 | if( 62 | $response->getStatus() != Response\Order\AbstractOrder::STATUS_VALID 63 | ) { 64 | 65 | Utilities\Logger::getInstance()->add( 66 | Utilities\Logger::LEVEL_DEBUG, 67 | static::class . '::' . __FUNCTION__ . ' (cache did not satisfy, status "' . $response->getStatus() . '")' 68 | ); 69 | 70 | $request = new Request\Order\Get($order, $response->getLocation()); 71 | $response = $request->getResponse(); 72 | $this->set($order, $response); 73 | return $response; 74 | } 75 | 76 | Utilities\Logger::getInstance()->add( 77 | Utilities\Logger::LEVEL_DEBUG, 78 | static::class . '::' . __FUNCTION__ . ' (from cache, status "' . $response->getStatus() . '")' 79 | ); 80 | 81 | $this->_responses[$accountIdentifier][$orderIdentifier] = $response; 82 | 83 | return $response; 84 | } 85 | 86 | throw new \RuntimeException( 87 | self::_FILE . ' could not be found for order: ' . 88 | '- Path: ' . $order->getKeyDirectoryPath() . PHP_EOL . 89 | '- Subjects: ' . var_export($order->getSubjects(), true) . PHP_EOL 90 | ); 91 | } 92 | 93 | public function set(Order $order, Response\Order\AbstractOrder $response = null) : void { 94 | 95 | $accountIdentifier = $this->_getObjectIdentifier($order->getAccount()); 96 | $orderIdentifier = $this->_getObjectIdentifier($order); 97 | 98 | $filePath = $order->getKeyDirectoryPath() . self::_FILE; 99 | 100 | if($response === null) { 101 | 102 | unset($this->_responses[$accountIdentifier][$orderIdentifier]); 103 | 104 | if(file_exists($filePath)) { 105 | unlink($filePath); 106 | } 107 | 108 | return; 109 | } 110 | 111 | $this->_responses[$accountIdentifier][$orderIdentifier] = $response; 112 | file_put_contents($filePath, $response->getRaw()->toString()); 113 | } 114 | } -------------------------------------------------------------------------------- /src/LE_ACME2/Connector/Connector.php: -------------------------------------------------------------------------------- 1 | _useStagingServer = $useStagingServer; 32 | } 33 | 34 | public function isUsingStagingServer() : bool { 35 | return $this->_useStagingServer; 36 | } 37 | 38 | public function getBaseURL() : string { 39 | return $this->_useStagingServer ? $this->_stagingBaseURL : $this->_baseURL; 40 | } 41 | 42 | /** 43 | * Delay the response to prevent bleaching rate limits 44 | */ 45 | public function delayResponse(int $milliSeconds) : void { 46 | $this->_delayedResponseTime = $milliSeconds; 47 | } 48 | 49 | /** 50 | * Makes a Curl request. 51 | * 52 | * @param string $method The HTTP method to use. Accepting GET, POST and HEAD requests. 53 | * @param string $url The URL to make the request to. 54 | * @param string|null $data The body to attach to a POST request. Expected as a JSON encoded string. 55 | * 56 | * @throws Exception\InvalidResponse 57 | * @throws Exception\RateLimitReached 58 | * @throws Exception\ServiceUnavailable 59 | */ 60 | public function request(string $method, string $url, string $data = null) : RawResponse { 61 | 62 | Utilities\Event::getInstance()->trigger(Utilities\Event::EVENT_CONNECTOR_WILL_REQUEST, [ 63 | 'method' => $method, 64 | 'url' => $url, 65 | 'data' => $data, 66 | ]); 67 | Utilities\Logger::getInstance()->add(Utilities\Logger::LEVEL_INFO, 'will request from ' . $url, ['data' => $data]); 68 | 69 | $handle = curl_init(); 70 | 71 | $headers = array( 72 | 'Accept: application/json', 73 | 'Content-Type: ' . ($method == self::METHOD_POST ? 'application/jose+json' : 'application/json') // ACME draft-10, section 6.2 74 | ); 75 | 76 | curl_setopt($handle, CURLOPT_URL, $url); 77 | curl_setopt($handle, CURLOPT_HTTPHEADER, $headers); 78 | curl_setopt($handle, CURLOPT_RETURNTRANSFER, true); 79 | curl_setopt($handle, CURLOPT_HEADER, true); 80 | 81 | switch ($method) { 82 | case self::METHOD_GET: 83 | break; 84 | case self::METHOD_POST: 85 | curl_setopt($handle, CURLOPT_POST, true); 86 | curl_setopt($handle, CURLOPT_POSTFIELDS, $data); 87 | break; 88 | case self::METHOD_HEAD: 89 | curl_setopt($handle, CURLOPT_CUSTOMREQUEST, 'HEAD'); 90 | curl_setopt($handle, CURLOPT_NOBODY, true); 91 | break; 92 | default: 93 | throw new \RuntimeException('HTTP request ' . $method . ' not supported.'); 94 | break; 95 | } 96 | $response = curl_exec($handle); 97 | 98 | if($this->_delayedResponseTime > 0) { 99 | usleep($this->_delayedResponseTime * 1000); 100 | } 101 | 102 | if(curl_errno($handle)) { 103 | throw new \RuntimeException('Curl: ' . curl_error($handle)); 104 | } 105 | 106 | $header_size = curl_getinfo($handle, CURLINFO_HEADER_SIZE); 107 | 108 | $rawResponse = RawResponse::createFrom($method, $url, $response, $header_size); 109 | 110 | Utilities\Logger::getInstance()->add(Utilities\Logger::LEVEL_INFO, self::class . ': response received', [get_class($rawResponse) => $rawResponse]); 111 | 112 | $this->_saveNewNonceFrom($rawResponse, $method); 113 | 114 | return $rawResponse; 115 | } 116 | 117 | /** 118 | * @throws Exception\InvalidResponse 119 | * @throws Exception\RateLimitReached 120 | * @throws Exception\ServiceUnavailable 121 | */ 122 | private function _saveNewNonceFrom(RawResponse $rawResponse, string $method) : void { 123 | 124 | try { 125 | $getNewNonceResponse = new Response\GetNewNonce($rawResponse); 126 | Cache\NewNonceResponse::getInstance()->set($getNewNonceResponse); 127 | 128 | } catch(Exception\InvalidResponse $e) { 129 | 130 | if($method == self::METHOD_POST) { 131 | $request = new Request\GetNewNonce(); 132 | $getNewNonceResponse = $request->getResponse(); 133 | Cache\NewNonceResponse::getInstance()->set($getNewNonceResponse); 134 | } 135 | } 136 | } 137 | } -------------------------------------------------------------------------------- /src/LE_ACME2/Connector/RawResponse.php: -------------------------------------------------------------------------------- 1 | request = $method . ' ' . $url; 26 | 27 | $result->header = array_map(function($line) { 28 | return trim($line); 29 | }, explode("\n", $header)); 30 | 31 | $result->body = $body_json === null ? $body : $body_json; 32 | 33 | return $result; 34 | } 35 | 36 | public function toString() : string { 37 | 38 | return serialize([ 39 | 'request' => $this->request, 40 | 'header' => $this->header, 41 | 'body' => $this->body, 42 | ]); 43 | } 44 | 45 | public static function getFromString(string $string) : self { 46 | 47 | $array = unserialize($string); 48 | 49 | $rawResponse = new self(); 50 | 51 | $rawResponse->request = $array['request']; 52 | $rawResponse->header = $array['header']; 53 | $rawResponse->body = $array['body']; 54 | 55 | return $rawResponse; 56 | } 57 | } -------------------------------------------------------------------------------- /src/LE_ACME2/Exception/AbstractException.php: -------------------------------------------------------------------------------- 1 | add( 12 | Utilities\Logger::LEVEL_DEBUG, 13 | 'Exception "' . get_called_class() . '" thrown ' 14 | ); 15 | 16 | parent::__construct($message); 17 | } 18 | } -------------------------------------------------------------------------------- /src/LE_ACME2/Exception/AuthorizationInvalid.php: -------------------------------------------------------------------------------- 1 | _rawResponse = $rawResponse; 19 | $this->_responseStatus = $responseStatus; 20 | 21 | if($responseStatus === '') { 22 | $responseStatus = 'Unknown response status'; 23 | } 24 | 25 | if(isset($this->_rawResponse->body['type'])) { 26 | $responseStatus = $this->_responseStatusType = $this->_rawResponse->body['type']; 27 | } 28 | 29 | if(isset($this->_rawResponse->body['detail'])) { 30 | $responseStatus .= ' - ' . $this->_rawResponse->body['detail']; 31 | } 32 | 33 | parent::__construct('Invalid response received: ' . $responseStatus); 34 | } 35 | 36 | public function getRawResponse() : RawResponse { 37 | return $this->_rawResponse; 38 | } 39 | 40 | public function getResponseStatus() : ?string { 41 | return $this->_responseStatus; 42 | } 43 | 44 | public function getResponseStatusType() : ?string { 45 | return $this->_responseStatusType; 46 | } 47 | 48 | public function getResponseStatusDetail() : ?string { 49 | 50 | if(isset($this->_rawResponse->body['detail'])) { 51 | return $this->_rawResponse->body['detail']; 52 | } 53 | return null; 54 | } 55 | } -------------------------------------------------------------------------------- /src/LE_ACME2/Exception/OpenSSLException.php: -------------------------------------------------------------------------------- 1 | response = $response; 15 | } 16 | } -------------------------------------------------------------------------------- /src/LE_ACME2/Exception/RateLimitReached.php: -------------------------------------------------------------------------------- 1 | retryAfter = $retryAfter; 20 | } 21 | 22 | /** 23 | * Returns the value of the given Retry-After header 24 | * 25 | * Retry-After: 26 | * Retry-After: 27 | * @see https://developer.mozilla.org/en-US/docs/Web/HTTP/Headers/Retry-After 28 | * 29 | * @return ?string "http-date" or "delay-seconds" or null (when not given) 30 | */ 31 | public function getRetryAfter() : ?string { 32 | return $this->retryAfter; 33 | } 34 | } -------------------------------------------------------------------------------- /src/LE_ACME2/Exception/StatusInvalid.php: -------------------------------------------------------------------------------- 1 | 1) 43 | throw new \RuntimeException('Cannot create orders with multiple wildcards in one domain.'); 44 | 45 | }, $subjects); 46 | 47 | $this->_account = $account; 48 | $this->_subjects = $subjects; 49 | 50 | $this->_identifier = $this->_getAccountIdentifier($account) . DIRECTORY_SEPARATOR . 51 | 'order_' . md5(implode('|', $subjects)); 52 | 53 | Utilities\Logger::getInstance()->add( 54 | Utilities\Logger::LEVEL_DEBUG, 55 | static::class . '::' . __FUNCTION__ . 56 | ' subject: "' . implode(':', $this->getSubjects()) . '" ' . 57 | ' path: ' . $this->getKeyDirectoryPath() 58 | ); 59 | } 60 | 61 | public function getAccount() : Account { 62 | return $this->_account; 63 | } 64 | 65 | public function getSubjects() : array { 66 | 67 | return $this->_subjects; 68 | } 69 | 70 | /** 71 | * @throws Exception\AbstractException 72 | */ 73 | public static function create(Account $account, array $subjects, string $keyType = self::KEY_TYPE_RSA) : Order { 74 | 75 | Utilities\Logger::getInstance()->add( 76 | Utilities\Logger::LEVEL_INFO, 77 | static::class . '::' . __FUNCTION__ . ' "' . implode(':', $subjects) . '"' 78 | ); 79 | 80 | $order = new self($account, $subjects); 81 | $order->requestCreate($keyType, false); 82 | 83 | return $order; 84 | } 85 | 86 | /** 87 | * Request to create a new order 88 | * 89 | * @throws Exception\AbstractException 90 | */ 91 | public function requestCreate(string $keyType = self::KEY_TYPE_RSA, bool $ignoreIfKeysExist = false) : void { 92 | 93 | $this->_initKeyDirectory($keyType, $ignoreIfKeysExist); 94 | 95 | $request = new Request\Order\Create($this); 96 | 97 | try { 98 | $response = $request->getResponse(); 99 | 100 | Cache\OrderResponse::getInstance()->set($this, $response); 101 | 102 | } catch(Exception\AbstractException $e) { 103 | $this->_clearKeyDirectory(); 104 | throw $e; 105 | } 106 | } 107 | 108 | /** 109 | * Returns true, when a let's encrypt order exists 110 | * Returns false, when no order exists, because it was never created or cleared 111 | */ 112 | public static function exists(Account $account, array $subjects) : bool { 113 | 114 | $order = new self($account, $subjects); 115 | return $order->hasResponse(); 116 | } 117 | 118 | public function hasResponse() : bool { 119 | return Cache\OrderResponse::getInstance()->exists($this); 120 | } 121 | 122 | /** 123 | * Returns true, when a certificate bundle exists, irrespective from the existence of a let's encrypt order 124 | */ 125 | public static function existsCertificateBundle(Account $account, array $subjects) : bool { 126 | 127 | $order = new self($account, $subjects); 128 | return $order->isCertificateBundleAvailable(); 129 | } 130 | 131 | public static function get(Account $account, array $subjects) : Order { 132 | 133 | Utilities\Logger::getInstance()->add( 134 | Utilities\Logger::LEVEL_INFO, 135 | static::class . '::' . __FUNCTION__ . ' "' . implode(':', $subjects) . '"' 136 | ); 137 | 138 | $order = new self($account, $subjects); 139 | 140 | if(!self::exists($account, $subjects)) 141 | throw new \RuntimeException('Order does not exist'); 142 | 143 | return $order; 144 | } 145 | 146 | protected Authorizer\AbstractAuthorizer|Authorizer\HTTP|Authorizer\DNS|null $_authorizer = null; 147 | 148 | /** 149 | * @throws Exception\InvalidResponse 150 | * @throws Exception\RateLimitReached 151 | * @throws Exception\ExpiredAuthorization 152 | */ 153 | protected function _getAuthorizer(string $type) : Authorizer\AbstractAuthorizer|Authorizer\HTTP|Authorizer\DNS|null { 154 | 155 | if($this->_authorizer === null) { 156 | 157 | if($type == self::CHALLENGE_TYPE_HTTP) { 158 | $this->_authorizer = new Authorizer\HTTP($this->_account, $this); 159 | } else if($type == self::CHALLENGE_TYPE_DNS) { 160 | $this->_authorizer = new Authorizer\DNS($this->_account, $this); 161 | } else { 162 | throw new \RuntimeException('Challenge type not implemented'); 163 | } 164 | } 165 | return $this->_authorizer; 166 | } 167 | 168 | /** 169 | * The Authorization has expired, so we clean the complete order to restart again on the next call 170 | */ 171 | protected function _clearAfterExpiredAuthorization() { 172 | 173 | Utilities\Logger::getInstance()->add( 174 | Utilities\Logger::LEVEL_INFO, 175 | static::class . '::' . __FUNCTION__ . ' "Will clear after expired authorization' 176 | ); 177 | 178 | $this->clear(); 179 | } 180 | 181 | public function clear() { 182 | Cache\OrderResponse::getInstance()->set($this, null); 183 | $this->_clearKeyDirectory(); 184 | } 185 | 186 | /** 187 | * @throws Exception\InvalidResponse 188 | * @throws Exception\RateLimitReached 189 | */ 190 | public function shouldStartAuthorization(string $type) : bool { 191 | 192 | try { 193 | return $this->_getAuthorizer($type)->shouldStartAuthorization(); 194 | } catch(Exception\ExpiredAuthorization $e) { 195 | 196 | $this->_clearAfterExpiredAuthorization(); 197 | 198 | return false; 199 | } 200 | } 201 | 202 | /** 203 | * @throws Exception\InvalidResponse 204 | * @throws Exception\RateLimitReached 205 | * @throws Exception\AuthorizationInvalid 206 | */ 207 | public function authorize(string $type) : bool { 208 | 209 | try { 210 | $authorizer = $this->_getAuthorizer($type); 211 | $authorizer->progress(); 212 | 213 | return $authorizer->hasFinished(); 214 | 215 | } catch(Exception\ExpiredAuthorization $e) { 216 | 217 | $this->_clearAfterExpiredAuthorization(); 218 | 219 | return false; 220 | } 221 | } 222 | 223 | /** 224 | * @throws Exception\InvalidResponse 225 | * @throws Exception\RateLimitReached 226 | * @throws Exception\OpenSSLException 227 | * @throws Exception\ServiceUnavailable 228 | */ 229 | public function finalize() : void { 230 | 231 | if(!is_object($this->_authorizer) || !$this->_authorizer->hasFinished()) { 232 | 233 | throw new \RuntimeException('Not all challenges are valid. Please check result of authorize() first!'); 234 | } 235 | 236 | Utilities\Logger::getInstance()->add( 237 | Utilities\Logger::LEVEL_INFO, 238 | static::class . '::' . __FUNCTION__ . ' "Will finalize' 239 | ); 240 | 241 | $orderResponse = Cache\OrderResponse::getInstance()->get($this); 242 | 243 | if( 244 | $orderResponse->getStatus() == Response\Order\AbstractOrder::STATUS_PENDING /* DEPRECATED AFTER JULI 5TH 2018 */ || 245 | $orderResponse->getStatus() == Response\Order\AbstractOrder::STATUS_READY // ACME draft-12 Section 7.1.6 246 | ) { 247 | $request = new Request\Order\Finalize($this, $orderResponse); 248 | $orderResponse = $request->getResponse(); 249 | 250 | $this->_authorizer = null; // Reset Authorizer to prevent that the certificate is written multiple times, when this is called multiple times 251 | Cache\OrderResponse::getInstance()->set($this, $orderResponse); 252 | } 253 | 254 | if($orderResponse->getStatus() == Response\Order\AbstractOrder::STATUS_VALID) { 255 | 256 | $request = new Request\Order\GetCertificate($this, $orderResponse); 257 | $response = $request->getResponse(); 258 | 259 | $certificate = $response->getCertificate(); 260 | $intermediate = $response->getIntermediate(); 261 | 262 | //$certificateInfo = openssl_x509_parse($certificate); 263 | //$certificateValidToTimeTimestamp = $certificateInfo['validTo_time_t']; 264 | $intermediateInfo = openssl_x509_parse($intermediate); 265 | 266 | if(self::$_preferredChain !== null) { 267 | Utilities\Logger::getInstance()->add( 268 | Utilities\Logger::LEVEL_INFO, 269 | 'Preferred chain is set: ' . self::$_preferredChain, 270 | ); 271 | } 272 | 273 | $found = false; 274 | if(self::$_preferredChain !== null && $intermediateInfo['issuer']['CN'] != self::$_preferredChain) { 275 | 276 | Utilities\Logger::getInstance()->add( 277 | Utilities\Logger::LEVEL_INFO, 278 | 'Default certificate does not satisfy preferred chain, trying to fetch alternative' 279 | ); 280 | 281 | foreach($response->getAlternativeLinks() as $link) { 282 | 283 | $request = new Request\Order\GetCertificate($this, $orderResponse, $link); 284 | $response = $request->getResponse(); 285 | 286 | $alternativeCertificate = $response->getCertificate(); 287 | $alternativeIntermediate = $response->getIntermediate(); 288 | 289 | $intermediateInfo = openssl_x509_parse($intermediate); 290 | if($intermediateInfo['issuer']['CN'] != self::$_preferredChain) { 291 | continue; 292 | } 293 | 294 | $found = true; 295 | 296 | $certificate = $alternativeCertificate; 297 | $intermediate = $alternativeIntermediate; 298 | 299 | break; 300 | } 301 | 302 | if(!$found) { 303 | Utilities\Logger::getInstance()->add( 304 | Utilities\Logger::LEVEL_INFO, 305 | 'Preferred chain could not be satisfied, returning default chain' 306 | ); 307 | } 308 | } 309 | Cache\OrderAuthorizationResponse::getInstance()->clear($this); 310 | $this->_authorizer = null; // Reset Authorizer to prevent that the certificate is written multiple times, when this is called multiple times 311 | $this->_saveCertificate($certificate, $intermediate); 312 | } 313 | } 314 | 315 | private function _saveCertificate(string $certificate, string $intermediate) : void { 316 | 317 | $certificateInfo = openssl_x509_parse($certificate); 318 | $certificateValidToTimeTimestamp = $certificateInfo['validTo_time_t']; 319 | 320 | $path = $this->getKeyDirectoryPath() . self::BUNDLE_DIRECTORY_PREFIX . $certificateValidToTimeTimestamp . DIRECTORY_SEPARATOR; 321 | 322 | if(file_exists($path)) { 323 | throw new \RuntimeException('Target directory already exist? ' . $path); 324 | } 325 | 326 | mkdir($path); 327 | rename($this->getKeyDirectoryPath() . 'private.pem', $path . 'private.pem'); 328 | file_put_contents($path . 'certificate.crt', $certificate); 329 | file_put_contents($path . 'intermediate.pem', $intermediate); 330 | 331 | Utilities\Logger::getInstance()->add(Utilities\Logger::LEVEL_INFO, 'Certificate received'); 332 | } 333 | 334 | const BUNDLE_DIRECTORY_PREFIX = 'bundle_'; 335 | 336 | protected function _getLatestCertificateDirectory() : ?string { 337 | 338 | if(!file_exists($this->getKeyDirectoryPath())) { 339 | return null; 340 | } 341 | 342 | $files = scandir($this->getKeyDirectoryPath(), SORT_NUMERIC | SORT_DESC); 343 | foreach($files as $file) { 344 | if( 345 | substr($file, 0, strlen(self::BUNDLE_DIRECTORY_PREFIX)) == self::BUNDLE_DIRECTORY_PREFIX && 346 | is_dir($this->getKeyDirectoryPath() . $file) 347 | ) { 348 | return $file; 349 | } 350 | } 351 | return null; 352 | } 353 | 354 | public function isCertificateBundleAvailable() : bool { 355 | 356 | return $this->_getLatestCertificateDirectory() !== NULL; 357 | } 358 | 359 | public function getCertificateBundle() : Struct\CertificateBundle { 360 | 361 | if(!$this->isCertificateBundleAvailable()) { 362 | throw new \RuntimeException('There is no certificate available'); 363 | } 364 | 365 | $certificatePath = $this->getKeyDirectoryPath() . $this->_getLatestCertificateDirectory(); 366 | 367 | return new Struct\CertificateBundle( 368 | $certificatePath . DIRECTORY_SEPARATOR, 369 | 'private.pem', 370 | 'certificate.crt', 371 | 'intermediate.pem', 372 | self::_getExpireTimeFromCertificateDirectoryPath($certificatePath) 373 | ); 374 | } 375 | 376 | /** 377 | * @param string|null $keyType default KEY_TYPE_RSA 378 | * @param int|null $renewBefore Unix timestamp 379 | * @throws Exception\AbstractException 380 | */ 381 | public function enableAutoRenewal(string $keyType = null, int $renewBefore = null) : void { 382 | 383 | if($keyType === null) { 384 | $keyType = self::KEY_TYPE_RSA; 385 | } 386 | 387 | if(!$this->isCertificateBundleAvailable()) { 388 | throw new \RuntimeException('There is no certificate available'); 389 | } 390 | 391 | if($this->hasResponse()) { 392 | 393 | $orderResponse = Cache\OrderResponse::getInstance()->get($this); 394 | 395 | if( $orderResponse->getStatus() != Response\Order\AbstractOrder::STATUS_VALID ) { 396 | Utilities\Logger::getInstance()->add( 397 | Utilities\Logger::LEVEL_INFO, 398 | 'Auto renewal: failed - status is not valid: ' . $orderResponse->getStatus(), 399 | ); 400 | return; 401 | } 402 | } 403 | 404 | Utilities\Logger::getInstance()->add(Utilities\Logger::LEVEL_DEBUG,'Auto renewal triggered'); 405 | 406 | $directory = $this->_getLatestCertificateDirectory(); 407 | 408 | $expireTime = self::_getExpireTimeFromCertificateDirectoryPath($directory); 409 | 410 | if($renewBefore === null) { 411 | $renewBefore = strtotime('-30 days', $expireTime); 412 | } 413 | 414 | if($renewBefore < time()) { 415 | 416 | Utilities\Logger::getInstance()->add(Utilities\Logger::LEVEL_INFO,'Auto renewal: Will recreate order'); 417 | 418 | $this->requestCreate($keyType, true); 419 | } 420 | } 421 | 422 | /** 423 | * @param int $reason The reason to revoke the LetsEncrypt Order instance certificate. 424 | * Possible reasons can be found in section 5.3.1 of RFC5280. 425 | * @throws Exception\RateLimitReached 426 | * @throws Exception\ServiceUnavailable 427 | */ 428 | public function revokeCertificate(int $reason = 0) : bool { 429 | 430 | if(!$this->isCertificateBundleAvailable()) { 431 | throw new \RuntimeException('There is no certificate available to revoke'); 432 | } 433 | 434 | $bundle = $this->getCertificateBundle(); 435 | 436 | $request = new Request\Order\RevokeCertificate($bundle, $reason); 437 | 438 | try { 439 | /* $response = */ $request->getResponse(); 440 | rename( 441 | $this->getKeyDirectoryPath(), 442 | $this->_getKeyDirectoryPath('-revoked-' . microtime(true)) 443 | ); 444 | return true; 445 | } catch(Exception\InvalidResponse $e) { 446 | return false; 447 | } 448 | } 449 | 450 | protected static function _getExpireTimeFromCertificateDirectoryPath(string $path) : int { 451 | 452 | $stringPosition = strrpos($path, self::BUNDLE_DIRECTORY_PREFIX); 453 | if($stringPosition === false) { 454 | throw new \RuntimeException('ExpireTime not found in' . $path); 455 | } 456 | 457 | $expireTime = substr($path, $stringPosition + strlen(self::BUNDLE_DIRECTORY_PREFIX)); 458 | if( 459 | !is_numeric($expireTime) || 460 | $expireTime < strtotime('-10 years') || 461 | $expireTime > strtotime('+10 years') 462 | ) { 463 | throw new \RuntimeException('Unexpected expireTime: ' . $expireTime); 464 | } 465 | return (int)$expireTime; 466 | } 467 | } -------------------------------------------------------------------------------- /src/LE_ACME2/Request/AbstractRequest.php: -------------------------------------------------------------------------------- 1 | _account = $account; 20 | } 21 | 22 | /** 23 | * @throws Exception\InvalidResponse 24 | * @throws Exception\RateLimitReached 25 | * @throws Exception\ServiceUnavailable 26 | */ 27 | protected function _getRawResponse() : Connector\RawResponse { 28 | 29 | $payload = $this->_getPayload(); 30 | if(count($payload) == 0) { 31 | $payload['rand-' . rand(100000, 1000000)] = 1; 32 | } 33 | 34 | $kid = Utilities\RequestSigner::KID( 35 | $payload, 36 | Cache\AccountResponse::getInstance()->get($this->_account)->getLocation(), 37 | Cache\AccountResponse::getInstance()->get($this->_account)->getLocation(), 38 | Cache\NewNonceResponse::getInstance()->get()->getNonce(), 39 | $this->_account->getKeyDirectoryPath() 40 | ); 41 | 42 | $result = Connector\Connector::getInstance()->request( 43 | Connector\Connector::METHOD_POST, 44 | Cache\AccountResponse::getInstance()->get($this->_account)->getLocation(), 45 | $kid 46 | ); 47 | 48 | return $result; 49 | } 50 | 51 | abstract protected function _getPayload() : array; 52 | } -------------------------------------------------------------------------------- /src/LE_ACME2/Request/Account/ChangeKeys.php: -------------------------------------------------------------------------------- 1 | _account = $account; 21 | } 22 | 23 | /** 24 | * @throws Exception\InvalidResponse 25 | * @throws Exception\RateLimitReached 26 | * @throws Exception\ServiceUnavailable 27 | */ 28 | public function getResponse() : Response\Account\ChangeKeys { 29 | 30 | $currentPrivateKey = openssl_pkey_get_private( 31 | file_get_contents($this->_account->getKeyDirectoryPath() . 'private.pem') 32 | ); 33 | $currentPrivateKeyDetails = openssl_pkey_get_details($currentPrivateKey); 34 | 35 | /** 36 | * draft-13 Section 7.3.6 37 | * "newKey" is deprecated after August 23rd 2018 38 | */ 39 | $newPrivateKey = openssl_pkey_get_private( 40 | file_get_contents($this->_account->getKeyDirectoryPath() . 'private-replacement.pem') 41 | ); 42 | $newPrivateKeyDetails = openssl_pkey_get_details($newPrivateKey); 43 | 44 | $innerPayload = [ 45 | 'account' => Cache\AccountResponse::getInstance()->get($this->_account)->getLocation(), 46 | 'oldKey' => [ 47 | "kty" => "RSA", 48 | "n" => Utilities\Base64::UrlSafeEncode($currentPrivateKeyDetails["rsa"]["n"]), 49 | "e" => Utilities\Base64::UrlSafeEncode($currentPrivateKeyDetails["rsa"]["e"]) 50 | ], 51 | 'newKey' => [ 52 | "kty" => "RSA", 53 | "n" => Utilities\Base64::UrlSafeEncode($newPrivateKeyDetails["rsa"]["n"]), 54 | "e" => Utilities\Base64::UrlSafeEncode($newPrivateKeyDetails["rsa"]["e"]) 55 | ] 56 | ]; 57 | 58 | $outerPayload = Utilities\RequestSigner::JWK( 59 | $innerPayload, 60 | Cache\DirectoryResponse::getInstance()->get()->getKeyChange(), 61 | Cache\NewNonceResponse::getInstance()->get()->getNonce(), 62 | $this->_account->getKeyDirectoryPath(), 63 | 'private-replacement.pem' 64 | ); 65 | 66 | $data = Utilities\RequestSigner::KID( 67 | $outerPayload, 68 | Cache\AccountResponse::getInstance()->get($this->_account)->getLocation(), 69 | Cache\DirectoryResponse::getInstance()->get()->getKeyChange(), 70 | Cache\NewNonceResponse::getInstance()->get()->getNonce(), 71 | $this->_account->getKeyDirectoryPath(), 72 | 'private.pem' 73 | ); 74 | 75 | $result = Connector\Connector::getInstance()->request( 76 | Connector\Connector::METHOD_POST, 77 | Cache\DirectoryResponse::getInstance()->get()->getKeyChange(), 78 | $data 79 | ); 80 | 81 | return new Response\Account\ChangeKeys($result); 82 | } 83 | } 84 | -------------------------------------------------------------------------------- /src/LE_ACME2/Request/Account/Create.php: -------------------------------------------------------------------------------- 1 | _account = $account; 21 | } 22 | 23 | /** 24 | * @throws Exception\InvalidResponse 25 | * @throws Exception\RateLimitReached 26 | * @throws Exception\ServiceUnavailable 27 | */ 28 | public function getResponse() : Response\Account\Create { 29 | 30 | $payload = [ 31 | 'contact' => $this->_buildContactPayload($this->_account->getEmail()), 32 | 'termsOfServiceAgreed' => true, 33 | ]; 34 | 35 | $jwk = Utilities\RequestSigner::JWKString( 36 | $payload, 37 | Cache\DirectoryResponse::getInstance()->get()->getNewAccount(), 38 | Cache\NewNonceResponse::getInstance()->get()->getNonce(), 39 | $this->_account->getKeyDirectoryPath() 40 | ); 41 | 42 | $result = Connector\Connector::getInstance()->request( 43 | Connector\Connector::METHOD_POST, 44 | Cache\DirectoryResponse::getInstance()->get()->getNewAccount(), 45 | $jwk 46 | ); 47 | 48 | return new Response\Account\Create($result); 49 | } 50 | } -------------------------------------------------------------------------------- /src/LE_ACME2/Request/Account/Deactivate.php: -------------------------------------------------------------------------------- 1 | 'deactivated', 15 | ]; 16 | } 17 | 18 | /** 19 | * @throws Exception\InvalidResponse 20 | * @throws Exception\RateLimitReached 21 | * @throws Exception\ServiceUnavailable 22 | */ 23 | public function getResponse() : Response\Account\Deactivate { 24 | 25 | return new Response\Account\Deactivate($this->_getRawResponse()); 26 | } 27 | } 28 | -------------------------------------------------------------------------------- /src/LE_ACME2/Request/Account/Get.php: -------------------------------------------------------------------------------- 1 | _account = $account; 21 | } 22 | 23 | /** 24 | * @throws Exception\InvalidResponse 25 | * @throws Exception\RateLimitReached 26 | * @throws Exception\ServiceUnavailable 27 | */ 28 | public function getResponse() : Response\Account\Get { 29 | 30 | $payload = [ 31 | 'onlyReturnExisting' => true, 32 | ]; 33 | 34 | $jwk = Utilities\RequestSigner::JWKString( 35 | $payload, 36 | Cache\DirectoryResponse::getInstance()->get()->getNewAccount(), 37 | Cache\NewNonceResponse::getInstance()->get()->getNonce(), 38 | $this->_account->getKeyDirectoryPath() 39 | ); 40 | 41 | $result = Connector\Connector::getInstance()->request( 42 | Connector\Connector::METHOD_POST, 43 | Cache\DirectoryResponse::getInstance()->get()->getNewAccount(), 44 | $jwk 45 | ); 46 | 47 | return new Response\Account\Get($result); 48 | } 49 | } -------------------------------------------------------------------------------- /src/LE_ACME2/Request/Account/GetData.php: -------------------------------------------------------------------------------- 1 | _getRawResponse()); 24 | } 25 | } -------------------------------------------------------------------------------- /src/LE_ACME2/Request/Account/Update.php: -------------------------------------------------------------------------------- 1 | _newEmail = $newEmail; 20 | } 21 | 22 | protected function _getPayload() : array { 23 | 24 | return [ 25 | 'contact' => $this->_buildContactPayload($this->_newEmail), 26 | ]; 27 | } 28 | 29 | /** 30 | * @throws Exception\InvalidResponse 31 | * @throws Exception\RateLimitReached 32 | * @throws Exception\ServiceUnavailable 33 | */ 34 | public function getResponse() : Response\Account\Update { 35 | return new Response\Account\Update($this->_getRawResponse()); 36 | } 37 | } 38 | -------------------------------------------------------------------------------- /src/LE_ACME2/Request/Authorization/Get.php: -------------------------------------------------------------------------------- 1 | _account = $account; 23 | $this->_authorizationURL = $authorizationURL; 24 | } 25 | 26 | /** 27 | * @throws Exception\InvalidResponse 28 | * @throws Exception\RateLimitReached 29 | * @throws Exception\ExpiredAuthorization 30 | * @throws Exception\ServiceUnavailable 31 | */ 32 | public function getResponse() : Response\Authorization\Get { 33 | 34 | $kid = Utilities\RequestSigner::KID( 35 | null, 36 | Cache\AccountResponse::getInstance()->get($this->_account)->getLocation(), 37 | $this->_authorizationURL, 38 | Cache\NewNonceResponse::getInstance()->get()->getNonce(), 39 | $this->_account->getKeyDirectoryPath() 40 | ); 41 | 42 | $result = Connector\Connector::getInstance()->request( 43 | Connector\Connector::METHOD_POST, 44 | $this->_authorizationURL, 45 | $kid 46 | ); 47 | 48 | return new Response\Authorization\Get($result); 49 | } 50 | } -------------------------------------------------------------------------------- /src/LE_ACME2/Request/Authorization/Start.php: -------------------------------------------------------------------------------- 1 | _account = $account; 26 | $this->_order = $order; 27 | $this->_challenge = $challenge; 28 | } 29 | 30 | /** 31 | * @throws Exception\InvalidResponse 32 | * @throws Exception\RateLimitReached 33 | * @throws Exception\ExpiredAuthorization 34 | * @throws Exception\ServiceUnavailable 35 | */ 36 | public function getResponse() : Response\Authorization\Start { 37 | 38 | Cache\OrderAuthorizationResponse::getInstance()->clear($this->_order); 39 | 40 | $payload = [ 41 | 'keyAuthorization' => (new ChallengeAuthorizationKey($this->_account))->get($this->_challenge->token) 42 | ]; 43 | 44 | $kid = Utilities\RequestSigner::KID( 45 | $payload, 46 | Cache\AccountResponse::getInstance()->get($this->_account)->getLocation(), 47 | $this->_challenge->url, 48 | Cache\NewNonceResponse::getInstance()->get()->getNonce(), 49 | $this->_account->getKeyDirectoryPath() 50 | ); 51 | 52 | $result = Connector\Connector::getInstance()->request( 53 | Connector\Connector::METHOD_POST, 54 | $this->_challenge->url, 55 | $kid 56 | ); 57 | 58 | return new Response\Authorization\Start($result); 59 | } 60 | } -------------------------------------------------------------------------------- /src/LE_ACME2/Request/GetDirectory.php: -------------------------------------------------------------------------------- 1 | request( 22 | Connector::METHOD_GET, 23 | $connector->getBaseURL() . '/directory' 24 | ); 25 | return new Response\GetDirectory($result); 26 | } 27 | } -------------------------------------------------------------------------------- /src/LE_ACME2/Request/GetNewNonce.php: -------------------------------------------------------------------------------- 1 | request( 21 | Connector\Connector::METHOD_HEAD, 22 | Cache\DirectoryResponse::getInstance()->get()->getNewNonce() 23 | ); 24 | 25 | return new Response\GetNewNonce($result); 26 | } 27 | } -------------------------------------------------------------------------------- /src/LE_ACME2/Request/Order/Create.php: -------------------------------------------------------------------------------- 1 | _order = $order; 22 | } 23 | 24 | /** 25 | * @throws Exception\InvalidResponse 26 | * @throws Exception\RateLimitReached 27 | * @throws Exception\ServiceUnavailable 28 | */ 29 | public function getResponse() : Response\Order\Create { 30 | 31 | $identifiers = []; 32 | foreach($this->_order->getSubjects() as $subject) { 33 | 34 | $identifiers[] = [ 35 | 'type' => 'dns', 36 | 'value' => $subject 37 | ]; 38 | } 39 | 40 | $payload = [ 41 | 'identifiers' => $identifiers, 42 | 'notBefore' => '', 43 | 'notAfter' => '', 44 | ]; 45 | 46 | $kid = Utilities\RequestSigner::KID( 47 | $payload, 48 | Cache\AccountResponse::getInstance()->get($this->_order->getAccount())->getLocation(), 49 | Cache\DirectoryResponse::getInstance()->get()->getNewOrder(), 50 | Cache\NewNonceResponse::getInstance()->get()->getNonce(), 51 | $this->_order->getAccount()->getKeyDirectoryPath() 52 | ); 53 | $result = Connector\Connector::getInstance()->request( 54 | Connector\Connector::METHOD_POST, 55 | Cache\DirectoryResponse::getInstance()->get()->getNewOrder(), 56 | $kid 57 | ); 58 | 59 | return new Response\Order\Create($result); 60 | } 61 | } -------------------------------------------------------------------------------- /src/LE_ACME2/Request/Order/Finalize.php: -------------------------------------------------------------------------------- 1 | _order = $order; 23 | $this->_orderResponse = $orderResponse; 24 | } 25 | 26 | /** 27 | * @throws Exception\InvalidResponse 28 | * @throws Exception\RateLimitReached 29 | * @throws Exception\OpenSSLException 30 | * @throws Exception\ServiceUnavailable 31 | */ 32 | public function getResponse() : Response\Order\Finalize { 33 | 34 | $csr = Utilities\Certificate::generateCSR($this->_order); 35 | 36 | if(preg_match('~-----BEGIN\sCERTIFICATE\sREQUEST-----(.*)-----END\sCERTIFICATE\sREQUEST-----~s', $csr, $matches)) 37 | $csr = $matches[1]; 38 | 39 | $csr = trim(Utilities\Base64::UrlSafeEncode(base64_decode($csr))); 40 | 41 | $payload = [ 42 | 'csr' => $csr 43 | ]; 44 | 45 | $kid = Utilities\RequestSigner::KID( 46 | $payload, 47 | Cache\AccountResponse::getInstance()->get($this->_order->getAccount())->getLocation(), 48 | $this->_orderResponse->getFinalize(), 49 | Cache\NewNonceResponse::getInstance()->get()->getNonce(), 50 | $this->_order->getAccount()->getKeyDirectoryPath() 51 | ); 52 | 53 | $result = Connector\Connector::getInstance()->request( 54 | Connector\Connector::METHOD_POST, 55 | $this->_orderResponse->getFinalize(), 56 | $kid 57 | ); 58 | 59 | return new Response\Order\Finalize($result); 60 | } 61 | } -------------------------------------------------------------------------------- /src/LE_ACME2/Request/Order/Get.php: -------------------------------------------------------------------------------- 1 | _order = $order; 23 | $this->location = $location; 24 | } 25 | 26 | /** 27 | * @throws Exception\InvalidResponse 28 | * @throws Exception\RateLimitReached 29 | * @throws Exception\ServiceUnavailable 30 | */ 31 | public function getResponse() : Response\Order\Get { 32 | 33 | $kid = Utilities\RequestSigner::KID( 34 | null, 35 | Cache\AccountResponse::getInstance()->get($this->_order->getAccount())->getLocation(), 36 | $this->location, 37 | Cache\NewNonceResponse::getInstance()->get()->getNonce(), 38 | $this->_order->getAccount()->getKeyDirectoryPath() 39 | ); 40 | 41 | $result = Connector\Connector::getInstance()->request( 42 | Connector\Connector::METHOD_POST, 43 | $this->location, 44 | $kid 45 | ); 46 | 47 | return new Response\Order\Get($result, $this->location); 48 | } 49 | } -------------------------------------------------------------------------------- /src/LE_ACME2/Request/Order/GetCertificate.php: -------------------------------------------------------------------------------- 1 | _order = $order; 25 | $this->_orderResponse = $orderResponse; 26 | 27 | if($alternativeUrl !== null) { 28 | $this->_alternativeUrl = $alternativeUrl; 29 | } 30 | } 31 | 32 | /** 33 | * @throws Exception\InvalidResponse 34 | * @throws Exception\RateLimitReached 35 | * @throws Exception\ServiceUnavailable 36 | */ 37 | public function getResponse() : Response\Order\GetCertificate { 38 | 39 | $url = $this->_alternativeUrl === null ? 40 | $this->_orderResponse->getCertificate() : 41 | $this->_alternativeUrl; 42 | 43 | $kid = Utilities\RequestSigner::KID( 44 | null, 45 | Cache\AccountResponse::getInstance()->get($this->_order->getAccount())->getLocation(), 46 | $url, 47 | Cache\NewNonceResponse::getInstance()->get()->getNonce(), 48 | $this->_order->getAccount()->getKeyDirectoryPath() 49 | ); 50 | 51 | $result = Connector\Connector::getInstance()->request( 52 | Connector\Connector::METHOD_POST, 53 | $url, 54 | $kid 55 | ); 56 | 57 | return new Response\Order\GetCertificate($result); 58 | } 59 | } -------------------------------------------------------------------------------- /src/LE_ACME2/Request/Order/RevokeCertificate.php: -------------------------------------------------------------------------------- 1 | _certificateBundle = $certificateBundle; 22 | $this->_reason = $reason; 23 | } 24 | 25 | /** 26 | * @throws Exception\InvalidResponse 27 | * @throws Exception\RateLimitReached 28 | * @throws Exception\ServiceUnavailable 29 | */ 30 | public function getResponse() : Response\Order\RevokeCertificate { 31 | 32 | $certificate = file_get_contents($this->_certificateBundle->path . $this->_certificateBundle->certificate); 33 | preg_match('~-----BEGIN\sCERTIFICATE-----(.*)-----END\sCERTIFICATE-----~s', $certificate, $matches); 34 | $certificate = trim(Utilities\Base64::UrlSafeEncode(base64_decode(trim($matches[1])))); 35 | 36 | $payload = [ 37 | 'certificate' => $certificate, 38 | 'reason' => $this->_reason 39 | ]; 40 | 41 | $jwk = Utilities\RequestSigner::JWKString( 42 | $payload, 43 | Cache\DirectoryResponse::getInstance()->get()->getRevokeCert(), 44 | Cache\NewNonceResponse::getInstance()->get()->getNonce(), 45 | $this->_certificateBundle->path, 46 | $this->_certificateBundle->private 47 | ); 48 | 49 | $result = Connector\Connector::getInstance()->request( 50 | Connector\Connector::METHOD_POST, 51 | Cache\DirectoryResponse::getInstance()->get()->getRevokeCert(), 52 | $jwk 53 | ); 54 | 55 | return new Response\Order\RevokeCertificate($result); 56 | } 57 | } -------------------------------------------------------------------------------- /src/LE_ACME2/Response/AbstractResponse.php: -------------------------------------------------------------------------------- 1 | _raw = $raw; 27 | 28 | if($this->_isServiceUnavailable()) { 29 | 30 | $detail = ""; 31 | if(isset($raw->body['detail'])) { 32 | $detail = $raw->body['detail']; 33 | } 34 | 35 | $retryAfterMatches = $this->_preg_match_headerLine('/^Retry-After: (.+)$/i'); 36 | 37 | throw new Exception\ServiceUnavailable( 38 | $raw->request, 39 | $detail, 40 | $retryAfterMatches !== null ? $retryAfterMatches[1] : null 41 | ); 42 | } 43 | 44 | if($this->_isRateLimitReached()) { 45 | 46 | $detail = ""; 47 | if(isset($raw->body['type']) && $raw->body['type'] == 'urn:ietf:params:acme:error:rateLimited') { 48 | $detail = $raw->body['detail']; 49 | } 50 | 51 | throw new Exception\RateLimitReached( 52 | $raw->request, 53 | $detail, 54 | ); 55 | } 56 | 57 | $result = $this->_isValid(); 58 | if(!$result) { 59 | 60 | $responseStatus = $this->_preg_match_headerLine('/^HTTP\/.* [0-9]{3,} /i'); 61 | throw new Exception\InvalidResponse( 62 | $raw, 63 | isset($responseStatus[1]) ? $responseStatus[1] : null, 64 | ); 65 | } 66 | } 67 | 68 | protected function _preg_match_headerLine(string $pattern) : ?array { 69 | 70 | foreach($this->_raw->header as $line) { 71 | 72 | $matches = []; 73 | if(preg_match($pattern, $line, $matches) === 1) { 74 | return $matches; 75 | } 76 | } 77 | return null; 78 | } 79 | 80 | protected function _isRateLimitReached() : bool { 81 | return $this->_preg_match_headerLine('/^HTTP\/.* 429/i') !== null; 82 | } 83 | 84 | protected function _isServiceUnavailable() : bool { 85 | return $this->_preg_match_headerLine('/^HTTP\/.* 503/i') !== null; 86 | } 87 | 88 | protected function _isValid() : bool { 89 | 90 | return $this->_preg_match_headerLine('/^HTTP\/.* 201/i') !== null || //Created 91 | $this->_preg_match_headerLine('/^HTTP\/.* 200/i') !== null || 92 | $this->_preg_match_headerLine('/^HTTP\/.* 204/i') !== null; 93 | } 94 | 95 | public function getRaw() : RawResponse { 96 | return $this->_raw; 97 | } 98 | } -------------------------------------------------------------------------------- /src/LE_ACME2/Response/Account/AbstractAccount.php: -------------------------------------------------------------------------------- 1 | _preg_match_headerLine($this->_pattern_header_location); 15 | return trim($matches[1]); 16 | } 17 | } -------------------------------------------------------------------------------- /src/LE_ACME2/Response/Account/AbstractLocation.php: -------------------------------------------------------------------------------- 1 | _raw->body['key']; 11 | } 12 | 13 | public function getContact() : string { 14 | return $this->_raw->body['contact']; 15 | } 16 | 17 | public function getAgreement() : string { 18 | return $this->_raw->body['agreement']; 19 | } 20 | 21 | public function getInitialIP() : string { 22 | return $this->_raw->body['initialIp']; 23 | } 24 | 25 | public function getCreatedAt() : string { 26 | return $this->_raw->body['createdAt']; 27 | } 28 | 29 | public function getStatus() : string { 30 | return $this->_raw->body['status']; 31 | } 32 | } -------------------------------------------------------------------------------- /src/LE_ACME2/Response/Account/ChangeKeys.php: -------------------------------------------------------------------------------- 1 | _preg_match_headerLine('/^HTTP\/.* 404/i') !== null) { 39 | throw new Exception\ExpiredAuthorization(); 40 | } 41 | 42 | return parent::_isValid(); 43 | } 44 | } -------------------------------------------------------------------------------- /src/LE_ACME2/Response/Authorization/Get.php: -------------------------------------------------------------------------------- 1 | _raw->body['identifier']['type'], 13 | $this->_raw->body['identifier']['value'] 14 | ); 15 | } 16 | 17 | public function getStatus() : string { 18 | return $this->_raw->body['status']; 19 | } 20 | 21 | public function getExpires() : string { 22 | return $this->_raw->body['expires']; 23 | } 24 | 25 | public function getChallenges() : array { 26 | return $this->_raw->body['challenges']; 27 | } 28 | 29 | public function getChallenge(string $type) : ?Struct\Challenge { 30 | 31 | foreach($this->getChallenges() as $challenge) { 32 | 33 | if($type == $challenge['type']) { 34 | 35 | $error = null; 36 | if(isset($challenge[ 'error' ]) && $challenge[ 'error' ] != "") { 37 | $error = new Struct\ChallengeError( 38 | $challenge[ 'error' ][ 'type' ], 39 | $challenge[ 'error' ][ 'detail' ], 40 | $challenge[ 'error' ][ 'status' ], 41 | ); 42 | } 43 | 44 | return new Struct\Challenge( 45 | $challenge[ 'type' ], 46 | $challenge[ 'status' ], 47 | $challenge[ 'url' ], 48 | $challenge[ 'token' ], 49 | $error, 50 | ); 51 | } 52 | } 53 | 54 | // There is not a challenge for a specific type, when the subject is already authorized by another 55 | // authorize type, f.e. when switching from http-01 to dns-01 56 | 57 | return null; 58 | } 59 | } -------------------------------------------------------------------------------- /src/LE_ACME2/Response/Authorization/Start.php: -------------------------------------------------------------------------------- 1 | type = $type; 23 | $this->status = $status; 24 | $this->url = $url; 25 | $this->token = $token; 26 | $this->error = $error; 27 | } 28 | } -------------------------------------------------------------------------------- /src/LE_ACME2/Response/Authorization/Struct/ChallengeError.php: -------------------------------------------------------------------------------- 1 | type = $type; 21 | $this->detail = $detail; 22 | $this->status = $status; 23 | } 24 | 25 | public static function createFrom(array $array) : static { 26 | return new static( 27 | $array['type'], 28 | $array['detail'], 29 | $array['status'], 30 | ); 31 | } 32 | 33 | public function hasStatusServerError() : bool { 34 | return 35 | $this->status >= 500 36 | && $this->status < 600 37 | ; 38 | } 39 | } -------------------------------------------------------------------------------- /src/LE_ACME2/Response/Authorization/Struct/ChallengeErrorConstructorInterface.php: -------------------------------------------------------------------------------- 1 | type = $type; 13 | $this->value = $value; 14 | } 15 | } -------------------------------------------------------------------------------- /src/LE_ACME2/Response/GetDirectory.php: -------------------------------------------------------------------------------- 1 | _raw->body['keyChange']; 9 | } 10 | 11 | public function getNewAccount() : string { 12 | return $this->_raw->body['newAccount']; 13 | } 14 | 15 | public function getNewNonce() : string { 16 | return $this->_raw->body['newNonce']; 17 | } 18 | 19 | public function getNewOrder() : string { 20 | return $this->_raw->body['newOrder']; 21 | } 22 | 23 | public function getRevokeCert() : string { 24 | return $this->_raw->body['revokeCert']; 25 | } 26 | 27 | public function getTermsOfService() : string { 28 | return $this->_raw->body['meta']['termsOfService']; 29 | } 30 | 31 | public function getWebsite() : string { 32 | return $this->_raw->body['meta']['website']; 33 | } 34 | 35 | public function getCaaIdentities() : string { 36 | return $this->_raw->body['meta']['caaIdentities']; 37 | } 38 | } 39 | -------------------------------------------------------------------------------- /src/LE_ACME2/Response/GetNewNonce.php: -------------------------------------------------------------------------------- 1 | _preg_match_headerLine($this->_pattern) !== null; 11 | } 12 | 13 | public function getNonce() : string { 14 | 15 | $matches = $this->_preg_match_headerLine($this->_pattern); 16 | return trim($matches[1]); 17 | } 18 | } -------------------------------------------------------------------------------- /src/LE_ACME2/Response/Order/AbstractOrder.php: -------------------------------------------------------------------------------- 1 | _preg_match_headerLine($this->_pattern_header_location); 19 | return trim($matches[1]); 20 | } 21 | 22 | public function getStatus() : string { 23 | return $this->_raw->body['status']; 24 | } 25 | 26 | public function getExpires() : string { 27 | return $this->_raw->body['expires']; 28 | } 29 | 30 | public function getIdentifiers() : array { 31 | return $this->_raw->body['identifiers']; 32 | } 33 | 34 | /** 35 | * @return array Authorization urls 36 | */ 37 | public function getAuthorizations() : array { 38 | return $this->_raw->body['authorizations']; 39 | } 40 | 41 | public function getFinalize() : string { 42 | return $this->_raw->body['finalize']; 43 | } 44 | 45 | public function getCertificate() : string { 46 | return $this->_raw->body['certificate']; 47 | } 48 | 49 | /** 50 | * @throws Exception\OrderStatusInvalid 51 | */ 52 | protected function _isValid(): bool { 53 | 54 | if(!parent::_isValid()) { 55 | return false; 56 | } 57 | 58 | if( 59 | $this->getStatus() == AbstractOrder::STATUS_INVALID 60 | ) { 61 | throw new Exception\OrderStatusInvalid( 62 | '. Probably all authorizations have failed. ' . PHP_EOL . 63 | 'Please see: ' . $this->getLocation() . PHP_EOL . 64 | 'Continue by using $order->clear() after getting rid of the problem', 65 | $this, 66 | ); 67 | } 68 | 69 | return true; 70 | } 71 | 72 | public function getError() : ?Struct\OrderError { 73 | 74 | if( 75 | !isset($this->_raw->body['error']) 76 | || !is_array($this->_raw->body['error']) 77 | ) { 78 | return null; 79 | } 80 | 81 | return OrderError::createFrom($this->_raw->body['error']); 82 | } 83 | } -------------------------------------------------------------------------------- /src/LE_ACME2/Response/Order/Create.php: -------------------------------------------------------------------------------- 1 | header[] = 'Location: ' . $orderURL; 22 | 23 | parent::__construct($raw); 24 | } 25 | } -------------------------------------------------------------------------------- /src/LE_ACME2/Response/Order/GetCertificate.php: -------------------------------------------------------------------------------- 1 | _pattern, $this->_raw->body, $matches)) { 15 | 16 | return $matches[0][0]; 17 | } 18 | 19 | throw new \RuntimeException('Preg_match_all has returned false - invalid pattern?'); 20 | } 21 | 22 | public function getIntermediate() : string { 23 | 24 | if(preg_match_all($this->_pattern, $this->_raw->body, $matches)) { 25 | 26 | $result = ''; 27 | 28 | for($i=1; $i 40 | */ 41 | public function getAlternativeLinks() : array { 42 | 43 | $result = []; 44 | 45 | foreach($this->_raw->header as $line) { 46 | $matches = []; 47 | preg_match_all('/^link: <(.*)>;rel="alternate"$/', $line, $matches); 48 | 49 | if(isset($matches[1][0])) { 50 | $result[] = $matches[1][0]; 51 | } 52 | } 53 | 54 | return $result; 55 | } 56 | } -------------------------------------------------------------------------------- /src/LE_ACME2/Response/Order/RevokeCertificate.php: -------------------------------------------------------------------------------- 1 | path = $path; 16 | $this->private = $private; 17 | $this->certificate = $certificate; 18 | $this->intermediate = $intermediate; 19 | $this->expireTime = $expireTime; 20 | } 21 | } -------------------------------------------------------------------------------- /src/LE_ACME2/Struct/ChallengeAuthorizationKey.php: -------------------------------------------------------------------------------- 1 | _account = $account; 14 | } 15 | 16 | public function get(string $token) : string { 17 | return $token . '.' . $this->_getDigest(); 18 | } 19 | 20 | public function getEncoded(string $token) : string { 21 | return Utilities\Base64::UrlSafeEncode( 22 | hash('sha256', $this->get($token), true) 23 | ); 24 | } 25 | 26 | private function _getDigest() : string { 27 | 28 | $privateKey = openssl_pkey_get_private(file_get_contents($this->_account->getKeyDirectoryPath() . 'private.pem')); 29 | $details = openssl_pkey_get_details($privateKey); 30 | 31 | $header = array( 32 | "e" => Utilities\Base64::UrlSafeEncode($details["rsa"]["e"]), 33 | "kty" => "RSA", 34 | "n" => Utilities\Base64::UrlSafeEncode($details["rsa"]["n"]) 35 | 36 | ); 37 | return Utilities\Base64::UrlSafeEncode(hash('sha256', json_encode($header), true)); 38 | } 39 | } -------------------------------------------------------------------------------- /src/LE_ACME2/Utilities/Base64.php: -------------------------------------------------------------------------------- 1 | $order->getSubjects()[0] 17 | ]; 18 | 19 | $san = implode(",", array_map(function ($dns) { 20 | 21 | return "DNS:" . $dns; 22 | }, $order->getSubjects()) 23 | ); 24 | 25 | $configFilePath = $order->getKeyDirectoryPath() . 'csr_config'; 26 | 27 | $config = 'HOME = . 28 | RANDFILE = ' . $order->getKeyDirectoryPath() . '.rnd 29 | [ req ] 30 | default_bits = 4096 31 | default_keyfile = privkey.pem 32 | distinguished_name = req_distinguished_name 33 | req_extensions = v3_req 34 | [ req_distinguished_name ] 35 | countryName = Country Name (2 letter code) 36 | [ v3_req ] 37 | basicConstraints = CA:FALSE 38 | subjectAltName = ' . $san . ' 39 | keyUsage = nonRepudiation, digitalSignature, keyEncipherment'; 40 | 41 | file_put_contents($configFilePath, $config); 42 | 43 | $privateKey = openssl_pkey_get_private( 44 | file_get_contents($order->getKeyDirectoryPath() . 'private.pem') 45 | ); 46 | 47 | if($privateKey === false) { 48 | throw new OpenSSLException('openssl_pkey_get_private'); 49 | } 50 | 51 | $csr = openssl_csr_new( 52 | $dn, 53 | $privateKey, 54 | [ 55 | 'config' => $configFilePath, 56 | 'digest_alg' => 'sha256' 57 | ] 58 | ); 59 | 60 | if($csr === false) { 61 | throw new OpenSSLException('openssl_csr_new'); 62 | } 63 | 64 | if(!openssl_csr_export($csr, $csr)) { 65 | throw new OpenSSLException('openssl_csr_export'); 66 | } 67 | 68 | unlink($configFilePath); 69 | 70 | return $csr; 71 | } 72 | } -------------------------------------------------------------------------------- /src/LE_ACME2/Utilities/ChallengeHTTP.php: -------------------------------------------------------------------------------- 1 | _subscriber[$event])) { 21 | $this->_subscriber[$event] = []; 22 | } 23 | 24 | $this->_subscriber[$event][] = $callable; 25 | } 26 | 27 | public function trigger(string $event, array $payload = null) : void { 28 | 29 | Logger::getInstance()->add(Logger::LEVEL_DEBUG, 'Event triggered: ' . $event); 30 | 31 | if(!isset($this->_subscriber[$event])) { 32 | return; 33 | } 34 | 35 | foreach($this->_subscriber[$event] as $callable) { 36 | $callable($event, $payload); 37 | } 38 | } 39 | } -------------------------------------------------------------------------------- /src/LE_ACME2/Utilities/KeyGenerator.php: -------------------------------------------------------------------------------- 1 | OPENSSL_KEYTYPE_RSA, 18 | "private_key_bits" => 4096, 19 | ]); 20 | 21 | if(!openssl_pkey_export($res, $privateKey)) 22 | throw new \RuntimeException("RSA keypair export failed!"); 23 | 24 | $details = openssl_pkey_get_details($res); 25 | 26 | file_put_contents($directory . $privateKeyFile, $privateKey); 27 | file_put_contents($directory . $publicKeyFile, $details['key']); 28 | 29 | if(PHP_MAJOR_VERSION < 8) { 30 | // deprecated after PHP 8.0.0 and not needed anymore 31 | openssl_pkey_free($res); 32 | } 33 | } 34 | 35 | /** 36 | * Generates a new EC prime256v1 keypair and saves both keys to a new file. 37 | * 38 | * @param string $directory The directory in which to store the new keys. 39 | * @param string $privateKeyFile The filename for the private key file. 40 | * @param string $publicKeyFile The filename for the public key file. 41 | */ 42 | public static function EC(string $directory, string $privateKeyFile = 'private.pem', string $publicKeyFile = 'public.pem') { 43 | 44 | if (version_compare(PHP_VERSION, '7.1.0') == -1) 45 | throw new \RuntimeException("PHP 7.1+ required for EC keys"); 46 | 47 | $res = openssl_pkey_new([ 48 | "private_key_type" => OPENSSL_KEYTYPE_EC, 49 | "curve_name" => "prime256v1", 50 | ]); 51 | 52 | if(!openssl_pkey_export($res, $privateKey)) 53 | throw new \RuntimeException("EC keypair export failed!"); 54 | 55 | $details = openssl_pkey_get_details($res); 56 | 57 | file_put_contents($directory . $privateKeyFile, $privateKey); 58 | file_put_contents($directory . $publicKeyFile, $details['key']); 59 | 60 | if(PHP_MAJOR_VERSION < 8) { 61 | // deprecated after PHP 8.0.0 and not needed anymore 62 | openssl_pkey_free($res); 63 | } 64 | } 65 | } -------------------------------------------------------------------------------- /src/LE_ACME2/Utilities/Logger.php: -------------------------------------------------------------------------------- 1 | _desiredLevel = $desiredLevel; 21 | } 22 | 23 | private \Psr\Log\LoggerInterface|null $_psrLogger = null; 24 | 25 | public function setPSRLogger(\Psr\Log\LoggerInterface|null $psrLogger) : void { 26 | $this->_psrLogger = $psrLogger; 27 | } 28 | 29 | public function add(int $level, string $message, array $data = array()) : void { 30 | 31 | if($level > $this->_desiredLevel) 32 | return; 33 | 34 | if($this->_psrLogger) { 35 | 36 | if($level == self::LEVEL_INFO) { 37 | $this->_psrLogger->info($message, $data); 38 | return; 39 | } 40 | if($level == self::LEVEL_DEBUG) { 41 | $this->_psrLogger->debug($message, $data); 42 | return; 43 | } 44 | throw new \RuntimeException('Missing PSR Logger support for level: ' . $level); 45 | } 46 | 47 | $e = new \Exception(); 48 | $trace = $e->getTrace(); 49 | unset($trace[0]); 50 | 51 | $output = '' . date('d-m-Y H:i:s') . ': ' . $message . '
' . "\n"; 52 | 53 | if($this->_desiredLevel == self::LEVEL_DEBUG) { 54 | 55 | $step = 0; 56 | foreach ($trace as $traceItem) { 57 | 58 | if(!isset($traceItem['class']) || !isset($traceItem['function'])) { 59 | continue; 60 | } 61 | 62 | $output .= 'Trace #' . $step . ': ' . $traceItem['class'] . '::' . $traceItem['function'] . '
' . "\n"; 63 | $step++; 64 | } 65 | 66 | if ((is_array($data) && count($data) > 0) || !is_array($data)) 67 | $output .= "\n" .'
Data:
' . "\n" . '
' . var_export($data, true) . '
'; 68 | 69 | $output .= '

' . "\n\n"; 70 | } 71 | 72 | if(PHP_SAPI == 'cli') { 73 | 74 | $output = strip_tags($output); 75 | } 76 | echo $output; 77 | } 78 | } -------------------------------------------------------------------------------- /src/LE_ACME2/Utilities/RequestSigner.php: -------------------------------------------------------------------------------- 1 | add(Logger::LEVEL_DEBUG, 'JWK sign request for ' . $url, ['payload' => $payload]); 20 | 21 | $privateKey = openssl_pkey_get_private(file_get_contents($privateKeyDir . $privateKeyFile)); 22 | $details = openssl_pkey_get_details($privateKey); 23 | 24 | $protected = [ 25 | "alg" => "RS256", 26 | "jwk" => [ 27 | "kty" => "RSA", 28 | "n" => Base64::UrlSafeEncode($details["rsa"]["n"]), 29 | "e" => Base64::UrlSafeEncode($details["rsa"]["e"]), 30 | ], 31 | "nonce" => $nonce, 32 | "url" => $url 33 | ]; 34 | 35 | $payload64 = Base64::JSONUrlSafeEncode($payload); 36 | $protected64 = Base64::JSONUrlSafeEncode($protected); 37 | 38 | openssl_sign($protected64.'.'.$payload64, $signed, $privateKey, "SHA256"); 39 | $signed64 = Base64::UrlSafeEncode($signed); 40 | 41 | $data = array( 42 | 'protected' => $protected64, 43 | 'payload' => $payload64, 44 | 'signature' => $signed64 45 | ); 46 | 47 | return $data; 48 | } 49 | 50 | /** 51 | * Generates a JSON Web Key signature to attach to the request. 52 | * 53 | * @param array $payload The payload to add to the signature. 54 | * @param string $url The URL to use in the signature. 55 | * @param string $privateKeyDir The directory to get the private key from. Default to the account keys directory given in the constructor. (optional) 56 | * @param string $privateKeyFile The private key to sign the request with. Defaults to 'private.pem'. (optional) 57 | * 58 | * @return string Returns a JSON encoded string containing the signature. 59 | */ 60 | public static function JWKString(array $payload, string $url, string $nonce, string $privateKeyDir, string $privateKeyFile = 'private.pem') : string { 61 | 62 | $jwk = self::JWK($payload, $url, $nonce, $privateKeyDir, $privateKeyFile); 63 | return json_encode($jwk); 64 | } 65 | 66 | /** 67 | * Generates a Key ID signature to attach to the request. 68 | * 69 | * @param array|null $payload The payload to add to the signature. 70 | * @param string $kid The Key ID to use in the signature. 71 | * @param string $url The URL to use in the signature. 72 | * @param string $privateKeyDir The directory to get the private key from. 73 | * @param string $privateKeyFile The private key to sign the request with. Defaults to 'private.pem'. (optional) 74 | * 75 | * @return string Returns a JSON encoded string containing the signature. 76 | */ 77 | public static function KID(?array $payload, string $kid, string $url, string $nonce, string $privateKeyDir, string $privateKeyFile = 'private.pem') : string { 78 | 79 | Logger::getInstance()->add(Logger::LEVEL_DEBUG, 'KID sign request for ' . $url, ['payload' => $payload]); 80 | 81 | $privateKey = openssl_pkey_get_private(file_get_contents($privateKeyDir . $privateKeyFile)); 82 | // TODO: unused - $details = openssl_pkey_get_details($privateKey); 83 | 84 | $protected = [ 85 | "alg" => "RS256", 86 | "kid" => $kid, 87 | "nonce" => $nonce, 88 | "url" => $url 89 | ]; 90 | 91 | Logger::getInstance()->add(Logger::LEVEL_DEBUG, 'KID: ready to sign request for: ' . $url, ['protected' => $protected]); 92 | 93 | $payload64 = $payload === null ? Base64::UrlSafeEncode('') : Base64::JSONUrlSafeEncode($payload); 94 | $protected64 = Base64::JSONUrlSafeEncode($protected); 95 | 96 | openssl_sign($protected64.'.'.$payload64, $signed, $privateKey, "SHA256"); 97 | $signed64 = Base64::UrlSafeEncode($signed); 98 | 99 | $data = [ 100 | 'protected' => $protected64, 101 | 'payload' => $payload64, 102 | 'signature' => $signed64 103 | ]; 104 | 105 | return json_encode($data); 106 | } 107 | } -------------------------------------------------------------------------------- /src/LE_ACME2Tests/AbstractLeAcme2TestCase.php: -------------------------------------------------------------------------------- 1 | _accountEmail = 'le_acme2_php' . phpversion() . '_client@test.com'; 16 | 17 | parent::__construct($name); 18 | 19 | $this->_orderSubjects[] = 'test.de'; 20 | 21 | $this->_umlautsOrderSubjects[] = 'xn--test--kra0kxb.de'; // test-üäö.de 22 | 23 | LE_ACME2\Connector\Connector::getInstance()->useStagingServer(true); 24 | } 25 | } -------------------------------------------------------------------------------- /src/LE_ACME2Tests/AccountTest.php: -------------------------------------------------------------------------------- 1 | _commonKeyDirectoryPath = TestHelper::getInstance()->getTempPath() . 'le-storage/'; 17 | } 18 | 19 | public function testNonExistingCommonKeyDirectoryPath() { 20 | 21 | $this->assertTrue(\LE_ACME2\Account::getCommonKeyDirectoryPath() === null); 22 | 23 | $notExistingPath = TestHelper::getInstance()->getTempPath() . 'should-not-exist/'; 24 | 25 | $this->catchExpectedException( 26 | \RuntimeException::class, 27 | function() use($notExistingPath) { 28 | \LE_ACME2\Account::setCommonKeyDirectoryPath($notExistingPath); 29 | } 30 | ); 31 | } 32 | 33 | public function testCommonKeyDirectoryPath() { 34 | 35 | if(!file_exists($this->_commonKeyDirectoryPath)) { 36 | mkdir($this->_commonKeyDirectoryPath); 37 | } 38 | 39 | \LE_ACME2\Account::setCommonKeyDirectoryPath($this->_commonKeyDirectoryPath); 40 | 41 | $this->assertTrue( 42 | \LE_ACME2\Account::getCommonKeyDirectoryPath() === $this->_commonKeyDirectoryPath 43 | ); 44 | } 45 | 46 | public function testNonExisting() { 47 | 48 | if(\LE_ACME2\Account::exists($this->_accountEmail)) { 49 | $this->markTestSkipped('Skipped: Account does already exist'); 50 | } 51 | 52 | $this->assertTrue(!\LE_ACME2\Account::exists($this->_accountEmail)); 53 | 54 | $this->catchExpectedException( 55 | \RuntimeException::class, 56 | function() { 57 | \LE_ACME2\Account::get($this->_accountEmail); 58 | } 59 | ); 60 | 61 | } 62 | 63 | public function testCreate() { 64 | 65 | if(\LE_ACME2\Account::exists($this->_accountEmail)) { 66 | // Skipping account modification tests, when the account already exists 67 | // to reduce the LE api usage while developing 68 | TestHelper::getInstance()->setSkipAccountModificationTests(true); 69 | $this->markTestSkipped('Account modifications skipped: Account does already exist'); 70 | } 71 | 72 | $this->assertTrue(!\LE_ACME2\Account::exists($this->_accountEmail)); 73 | 74 | $account = \LE_ACME2\Account::create($this->_accountEmail); 75 | $this->assertTrue(is_object($account)); 76 | $this->assertTrue($account->getEmail() === $this->_accountEmail); 77 | 78 | $account = \LE_ACME2\Account::get($this->_accountEmail); 79 | $this->assertTrue(is_object($account)); 80 | 81 | $result = $account->getData(); 82 | $this->assertTrue($result->getStatus() === \LE_ACME2\Response\Account\AbstractAccount::STATUS_VALID); 83 | } 84 | 85 | public function testInvalidCreate() { 86 | 87 | if(TestHelper::getInstance()->shouldSkipAccountModificationTests()) { 88 | $this->expectNotToPerformAssertions(); 89 | return; 90 | } 91 | 92 | $e = $this->catchExpectedException( 93 | InvalidResponse::class, 94 | function() { 95 | \LE_ACME2\Account::create('test_php' . phpversion() . '@example.org'); 96 | } 97 | ); 98 | $this->assertEquals( 99 | 'Invalid response received: ' . 100 | 'urn:ietf:params:acme:error:invalidContact' . 101 | ' - ' . 102 | 'Error creating new account :: contact email has forbidden domain "example.org"', 103 | $e->getMessage(), 104 | ); 105 | } 106 | 107 | public function testModification() { 108 | 109 | if(TestHelper::getInstance()->shouldSkipAccountModificationTests()) { 110 | $this->expectNotToPerformAssertions(); 111 | return; 112 | } 113 | 114 | $account = \LE_ACME2\Account::get($this->_accountEmail); 115 | $this->assertTrue(is_object($account)); 116 | 117 | $keyDirectoryPath = $account->getKeyDirectoryPath(); 118 | $newEmail = 'new-' . $this->_accountEmail; 119 | 120 | // An email from example.org is not allowed 121 | $result = $account->update('test@example.org'); 122 | $this->assertTrue($result === false); 123 | 124 | $result = $account->update($newEmail); 125 | $this->assertTrue($result === true); 126 | 127 | $this->assertTrue($account->getKeyDirectoryPath() !== $keyDirectoryPath); 128 | $this->assertTrue(file_exists($account->getKeyDirectoryPath())); 129 | 130 | $result = $account->update($this->_accountEmail); 131 | $this->assertTrue($result === true); 132 | 133 | $result = $account->changeKeys(); 134 | $this->assertTrue($result === true); 135 | 136 | // 11. August 2022 137 | // Quickfix: The LE server will not recognize fast enough, that the account key has already changed 138 | sleep(5); 139 | } 140 | 141 | public function testDeactivation() { 142 | 143 | if(TestHelper::getInstance()->shouldSkipAccountModificationTests()) { 144 | $this->expectNotToPerformAssertions(); 145 | return; 146 | } 147 | 148 | $account = \LE_ACME2\Account::get($this->_accountEmail); 149 | $this->assertTrue(is_object($account)); 150 | 151 | $result = $account->deactivate(); 152 | $this->assertTrue($result === true); 153 | 154 | // 11. August 2022 155 | // Quickfix: The LE server will not recognize fast enough, that the account is already deactivated 156 | sleep(5); 157 | 158 | // The account is already deactivated 159 | $result = $account->deactivate(); 160 | $this->assertTrue($result === false); 161 | 162 | // The account is already deactivated 163 | $result = $account->changeKeys(); 164 | $this->assertTrue($result === false); 165 | 166 | // The account is already deactivated 167 | $this->catchExpectedException( 168 | \LE_ACME2\Exception\InvalidResponse::class, 169 | function() use($account) { 170 | $account->getData(); 171 | } 172 | ); 173 | } 174 | 175 | public function testCreationAfterDeactivation() { 176 | 177 | if(TestHelper::getInstance()->shouldSkipAccountModificationTests()) { 178 | $this->expectNotToPerformAssertions(); 179 | return; 180 | } 181 | 182 | $account = \LE_ACME2\Account::get($this->_accountEmail); 183 | $this->assertTrue(is_object($account)); 184 | 185 | system('rm -R ' . $account->getKeyDirectoryPath()); 186 | $this->assertTrue(!\LE_ACME2\Account::exists($this->_accountEmail)); 187 | 188 | $account = \LE_ACME2\Account::create($this->_accountEmail); 189 | $this->assertTrue(is_object($account)); 190 | } 191 | 192 | public function test() { 193 | 194 | $account = \LE_ACME2\Account::get($this->_accountEmail); 195 | $this->assertTrue(is_object($account)); 196 | } 197 | } -------------------------------------------------------------------------------- /src/LE_ACME2Tests/Authorizer/HTTPTest.php: -------------------------------------------------------------------------------- 1 | _directoryPath = TestHelper::getInstance()->getTempPath() . 'acme-challenges/'; 18 | } 19 | 20 | public function testNonExistingDirectoryPath() { 21 | 22 | $this->assertTrue(\LE_ACME2\Authorizer\HTTP::getDirectoryPath() === null); 23 | 24 | $this->catchExpectedException( 25 | \RuntimeException::class, 26 | function() { 27 | \LE_ACME2\Authorizer\HTTP::setDirectoryPath(TestHelper::getInstance()->getNonExistingPath()); 28 | } 29 | ); 30 | } 31 | 32 | public function testDirectoryPath() { 33 | 34 | if(!file_exists($this->_directoryPath)) { 35 | mkdir($this->_directoryPath); 36 | } 37 | 38 | \LE_ACME2\Authorizer\HTTP::setDirectoryPath($this->_directoryPath); 39 | 40 | $this->assertTrue( 41 | \LE_ACME2\Authorizer\HTTP::getDirectoryPath() === $this->_directoryPath 42 | ); 43 | } 44 | } -------------------------------------------------------------------------------- /src/LE_ACME2Tests/Connector/RawResponse.php: -------------------------------------------------------------------------------- 1 | assertEquals( 15 | $exception, 16 | get_class($e), 17 | 'Exception message: ' . $e->getMessage(), 18 | ); 19 | return $e; 20 | } 21 | 22 | throw new \RuntimeException('Expected exception not thrown: ' . $exception); 23 | } 24 | } -------------------------------------------------------------------------------- /src/LE_ACME2Tests/Exception/RateLimitReachedTest.php: -------------------------------------------------------------------------------- 1 | catchExpectedException(LE_ACME2\Exception\RateLimitReached::class, function() use($raw) { 29 | new LE_ACME2\Response\GetDirectory($raw); 30 | }); 31 | $this->assertIsObject($exception); 32 | $this->assertTrue(get_class($exception) == LE_ACME2\Exception\RateLimitReached::class); 33 | } 34 | } -------------------------------------------------------------------------------- /src/LE_ACME2Tests/Exception/ServiceUnavailableTest.php: -------------------------------------------------------------------------------- 1 | catchExpectedException(LE_ACME2\Exception\ServiceUnavailable::class, function() use($raw) { 29 | new LE_ACME2\Response\GetDirectory($raw); 30 | }); 31 | $this->assertIsObject($exception); 32 | $this->assertTrue(get_class($exception) == LE_ACME2\Exception\ServiceUnavailable::class); 33 | } 34 | 35 | /** 36 | * @covers LE_ACME2\Exception\ServiceUnavailable 37 | * @covers LE_ACME2\Response\AbstractResponse::_isServiceUnavailable 38 | * @covers LE_ACME2\Response\AbstractResponse::__construct 39 | * 40 | * @return void 41 | */ 42 | public function testRetryAfterHeader() { 43 | 44 | $raw = RawResponse::createDummyFrom( 45 | 'HTTP/2 503 Too many requests' . "\r\n" . 46 | 'Retry-After: 120', 47 | '{ 48 | "type": "urn:ietf:params:acme:error:rateLimited", 49 | "detail": "Service busy; retry later." 50 | }', 51 | ); 52 | 53 | /** @var LE_ACME2\Exception\ServiceUnavailable $exception */ 54 | $exception = $this->catchExpectedException(LE_ACME2\Exception\ServiceUnavailable::class, function() use($raw) { 55 | new LE_ACME2\Response\GetDirectory($raw); 56 | }); 57 | $this->assertEquals('120', $exception->getRetryAfter()); 58 | } 59 | } -------------------------------------------------------------------------------- /src/LE_ACME2Tests/OrderTest.php: -------------------------------------------------------------------------------- 1 | _accountEmail); 12 | 13 | if(\LE_ACME2\Order::exists($account, $this->_orderSubjects)) { 14 | $this->markTestSkipped('Skipped: Order does already exist'); 15 | } 16 | 17 | $this->assertFalse(\LE_ACME2\Order::exists($account, $this->_orderSubjects)); 18 | $this->assertFalse(\LE_ACME2\Order::existsCertificateBundle($account, $this->_orderSubjects)); 19 | 20 | $this->catchExpectedException( 21 | \RuntimeException::class, 22 | function() use($account) { 23 | \LE_ACME2\Order::get($account, $this->_orderSubjects); 24 | } 25 | ); 26 | } 27 | 28 | public function testCreate() { 29 | 30 | $account = \LE_ACME2\Account::get($this->_accountEmail); 31 | 32 | if(\LE_ACME2\Order::exists($account, $this->_orderSubjects)) { 33 | // Skipping order modification tests, when the order already exists 34 | // to reduce the LE api usage while developing 35 | TestHelper::getInstance()->setSkipOrderModificationTests(true); 36 | $this->markTestSkipped('Order modifications skipped: Order does already exist'); 37 | } 38 | 39 | $this->assertTrue(!\LE_ACME2\Order::exists($account, $this->_orderSubjects)); 40 | 41 | $order = \LE_ACME2\Order::create($account, $this->_orderSubjects); 42 | $this->assertTrue(is_object($order)); 43 | $this->assertTrue(count(array_diff($order->getSubjects(), $this->_orderSubjects)) == 0); 44 | 45 | $order = \LE_ACME2\Order::get($account, $this->_orderSubjects); 46 | $this->assertTrue(is_object($order)); 47 | 48 | // TODO: Order replacement? 49 | //$result = $order->get(); 50 | //$this->assertTrue($result->getStatus() === \LE_ACME2\Response\Account\AbstractAccount::STATUS_VALID); 51 | } 52 | 53 | public function testUmlautsCreate() { 54 | 55 | $account = \LE_ACME2\Account::get($this->_accountEmail); 56 | 57 | if(\LE_ACME2\Order::exists($account, $this->_umlautsOrderSubjects)) { 58 | // Skipping order modification tests, when the order already exists 59 | // to reduce the LE api usage while developing 60 | TestHelper::getInstance()->setSkipOrderModificationTests(true); 61 | $this->markTestSkipped('Order modifications skipped: Order does already exist'); 62 | } 63 | 64 | $this->assertTrue(!\LE_ACME2\Order::exists($account, $this->_umlautsOrderSubjects)); 65 | 66 | $order = \LE_ACME2\Order::create($account, $this->_umlautsOrderSubjects); 67 | $this->assertTrue(is_object($order)); 68 | $this->assertTrue(count(array_diff($order->getSubjects(), $this->_umlautsOrderSubjects)) == 0); 69 | 70 | $order = \LE_ACME2\Order::get($account, $this->_umlautsOrderSubjects); 71 | $this->assertTrue(is_object($order)); 72 | 73 | // TODO: Order replacement? 74 | //$result = $order->get(); 75 | //$this->assertTrue($result->getStatus() === \LE_ACME2\Response\Account\AbstractAccount::STATUS_VALID); 76 | } 77 | } -------------------------------------------------------------------------------- /src/LE_ACME2Tests/Response/Authorization/GetTest.php: -------------------------------------------------------------------------------- 1 | getChallenge('http-01'); 22 | 23 | $error = $challenge->error; 24 | $this->assertTrue(is_object($error) === true); 25 | $this->assertTrue($error->type === 'urn:ietf:params:acme:error:dns'); 26 | $this->assertTrue($error->detail === "DNS problem: SERVFAIL looking up CAA for domain1.tld - the domain's nameservers may be malfunctioning"); 27 | $this->assertTrue($error->status === 400); 28 | } 29 | } -------------------------------------------------------------------------------- /src/LE_ACME2Tests/Response/Order/GetTest.php: -------------------------------------------------------------------------------- 1 | catchExpectedException( 23 | LE_ACME2\Exception\OrderStatusInvalid::class, 24 | function() use($rawResponse) { 25 | new LE_ACME2\Response\Order\Get($rawResponse, 'http://dummy.org'); 26 | } 27 | ); 28 | 29 | try { 30 | new LE_ACME2\Response\Order\Get($rawResponse, 'http://dummy.org'); 31 | 32 | throw new \RuntimeException('Exception not thrown'); 33 | 34 | } catch (LE_ACME2\Exception\OrderStatusInvalid $e) { 35 | $this->assertNull($e->response->getError()); 36 | } 37 | } 38 | 39 | /** 40 | * @covers \LE_ACME2\Response\Order\Struct\OrderError 41 | */ 42 | public function testOrderInvalid() { 43 | 44 | $rawResponse = Connector\RawResponse::createDummyFrom( 45 | Connector\RawResponse::HEADER_200, 46 | file_get_contents(dirname(__FILE__, 2) . DIRECTORY_SEPARATOR . '_JSONSamples' . DIRECTORY_SEPARATOR . 'OrderStatusInvalidHavingError.json') 47 | ); 48 | 49 | try { 50 | new LE_ACME2\Response\Order\Get($rawResponse, 'http://dummy.org'); 51 | 52 | throw new \RuntimeException('Exception not thrown'); 53 | 54 | } catch (LE_ACME2\Exception\OrderStatusInvalid $e) { 55 | 56 | $error = $e->response->getError(); 57 | 58 | $this->assertNotNull($error); 59 | 60 | $this->assertTrue($e->response->getError()->hasStatusServerError()); 61 | 62 | $this->assertEquals(500, $e->response->getError()->status); 63 | $this->assertEquals('urn:ietf:params:acme:error:serverInternal', $e->response->getError()->type); 64 | $this->assertEquals('Error finalizing order :: Unable to meet CA SCT embedding requirements', $e->response->getError()->detail); 65 | } 66 | } 67 | } -------------------------------------------------------------------------------- /src/LE_ACME2Tests/Response/_JSONSamples/ChallengeError.json: -------------------------------------------------------------------------------- 1 | { 2 | "identifier": { 3 | "type": "dns", 4 | "value": "subdomain.domain1.tld" 5 | }, 6 | "status": "invalid", 7 | "expires": "2021-07-24T08:01:41Z", 8 | "challenges": [ 9 | { 10 | "type": "http-01", 11 | "status": "invalid", 12 | "error": { 13 | "type": "urn:ietf:params:acme:error:dns", 14 | "detail": "DNS problem: SERVFAIL looking up CAA for domain1.tld - the domain's nameservers may be malfunctioning", 15 | "status": 400 16 | }, 17 | "url": "https://acme-v02.api.letsencrypt.org/acme/chall-v3/1234567/beH7Ng", 18 | "token": "RPET-XXXBUHuxDLjO_XXXnG6c34I0U", 19 | "validationRecord": [ 20 | { 21 | "url": "http://subdomain.domain1.tld/.well-known/acme-challenge/RPET-XXXBUHuxDLjO_XXXnG6c34I0U", 22 | "hostname": "subdomain.domain1.tld", 23 | "port": "80", 24 | "addressesResolved": [ 25 | "111.222.333.444" 26 | ], 27 | "addressUsed": "111.222.333.444" 28 | }, 29 | { 30 | "url": "https://subdomain.domain1.tld/.well-known/acme-challenge/RPET-XXXBUHuxDLjO_XXXnG6c34I0U", 31 | "hostname": "subdomain.domain1.tld", 32 | "port": "443", 33 | "addressesResolved": [ 34 | "111.222.333.444" 35 | ], 36 | "addressUsed": "111.222.333.444" 37 | } 38 | ], 39 | "validated": "2021-07-17T08:02:10Z" 40 | } 41 | ] 42 | } -------------------------------------------------------------------------------- /src/LE_ACME2Tests/Response/_JSONSamples/OrderStatusInvalid.json: -------------------------------------------------------------------------------- 1 | { 2 | "status": "invalid", 3 | "expires": "2021-07-24T08:01:41Z", 4 | "identifiers": [ 5 | { 6 | "type": "dns", 7 | "value": "subdomain.domain1.tld" 8 | } 9 | ], 10 | "authorizations": [ 11 | "https://acme-v02.api.letsencrypt.org/acme/authz-v3/1234567" 12 | ], 13 | "finalize": "https://acme-v02.api.letsencrypt.org/acme/finalize/1234567/1234567" 14 | } -------------------------------------------------------------------------------- /src/LE_ACME2Tests/Response/_JSONSamples/OrderStatusInvalidHavingError.json: -------------------------------------------------------------------------------- 1 | { 2 | "status": "invalid", 3 | "expires": "2021-07-24T08:01:41Z", 4 | "identifiers": [ 5 | { 6 | "type": "dns", 7 | "value": "subdomain.domain1.tld" 8 | } 9 | ], 10 | "authorizations": [ 11 | "https://acme-v02.api.letsencrypt.org/acme/authz-v3/1234567" 12 | ], 13 | "finalize": "https://acme-v02.api.letsencrypt.org/acme/finalize/1234567/1234567", 14 | "error": { 15 | "type": "urn:ietf:params:acme:error:serverInternal", 16 | "detail": "Error finalizing order :: Unable to meet CA SCT embedding requirements", 17 | "status": 500 18 | } 19 | } -------------------------------------------------------------------------------- /src/LE_ACME2Tests/TestHelper.php: -------------------------------------------------------------------------------- 1 | _tempPath = $projectPath . 'temp/'; 17 | if( !file_exists($this->_tempPath) ) { 18 | mkdir($this->_tempPath); 19 | } 20 | 21 | $this->_nonExistingPath = $this->getTempPath() . 'should-not-exist/'; 22 | } 23 | 24 | public function getTempPath() : string { 25 | return $this->_tempPath; 26 | } 27 | 28 | public function getNonExistingPath() : string { 29 | return $this->_nonExistingPath; 30 | } 31 | 32 | private $_skipAccountModificationTests = false; 33 | 34 | public function setSkipAccountModificationTests(bool $value) : void { 35 | $this->_skipAccountModificationTests = $value; 36 | } 37 | 38 | public function shouldSkipAccountModificationTests() : bool { 39 | return $this->_skipAccountModificationTests; 40 | } 41 | 42 | private $_skipOrderModificationTests = false; 43 | 44 | public function setSkipOrderModificationTests(bool $value) : void { 45 | $this->_skipOrderModificationTests = $value; 46 | } 47 | 48 | public function shouldSkipOrderModificationTests() : bool { 49 | return $this->_skipOrderModificationTests; 50 | } 51 | } -------------------------------------------------------------------------------- /src/LE_ACME2Tests/Utilities/CertificateTest.php: -------------------------------------------------------------------------------- 1 | _accountEmail); 17 | 18 | $this->_testOrderGenerateCSR($account); 19 | $this->_testOrderUmlautsGenerateCSR($account); 20 | } 21 | 22 | private function _testOrderGenerateCSR(\LE_ACME2\Account $account) { 23 | 24 | $order = \LE_ACME2\Order::get($account, $this->_orderSubjects); 25 | 26 | $csr = Utilities\Certificate::generateCSR($order); 27 | $this->assertTrue($csr !== null && is_string($csr)); 28 | } 29 | 30 | private function _testOrderUmlautsGenerateCSR(\LE_ACME2\Account $account) { 31 | 32 | $order = \LE_ACME2\Order::get($account, $this->_umlautsOrderSubjects); 33 | 34 | $csr = Utilities\Certificate::generateCSR($order); 35 | $this->assertTrue($csr !== null && is_string($csr)); 36 | } 37 | } --------------------------------------------------------------------------------