diff --git a/.editorconfig b/.editorconfig new file mode 100644 index 0000000..cd8eb86 --- /dev/null +++ b/.editorconfig @@ -0,0 +1,15 @@ +; This file is for unifying the coding style for different editors and IDEs. +; More information at http://editorconfig.org + +root = true + +[*] +charset = utf-8 +indent_size = 4 +indent_style = space +end_of_line = lf +insert_final_newline = true +trim_trailing_whitespace = true + +[*.md] +trim_trailing_whitespace = false diff --git a/.gitattributes b/.gitattributes new file mode 100644 index 0000000..3286141 --- /dev/null +++ b/.gitattributes @@ -0,0 +1,11 @@ +# Path-based git attributes +# https://www.kernel.org/pub/software/scm/git/docs/gitattributes.html + +# Ignore all test and documentation with "export-ignore". +/.gitattributes export-ignore +/.gitignore export-ignore +/.travis.yml export-ignore +/phpunit.xml.dist export-ignore +/.scrutinizer.yml export-ignore +/tests export-ignore +/docs export-ignore \ No newline at end of file diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..81f3e08 --- /dev/null +++ b/.gitignore @@ -0,0 +1,7 @@ +.idea +build +composer.lock +vendor +examples/keys +certificates +keys diff --git a/.scrutinizer.yml b/.scrutinizer.yml new file mode 100644 index 0000000..244d880 --- /dev/null +++ b/.scrutinizer.yml @@ -0,0 +1,23 @@ +filter: + excluded_paths: [tests/*] + +checks: + php: + remove_extra_empty_lines: true + remove_php_closing_tag: true + remove_trailing_whitespace: true + fix_use_statements: + remove_unused: true + preserve_multiple: false + preserve_blanklines: true + order_alphabetically: true + fix_php_opening_tag: true + fix_linefeed: true + fix_line_ending: true + fix_identation_4spaces: true + fix_doc_comments: true + +tools: + external_code_coverage: + timeout: 600 + runs: 2 diff --git a/.styleci.yml b/.styleci.yml new file mode 100644 index 0000000..247a09c --- /dev/null +++ b/.styleci.yml @@ -0,0 +1 @@ +preset: psr2 diff --git a/.travis.yml b/.travis.yml new file mode 100644 index 0000000..da820c6 --- /dev/null +++ b/.travis.yml @@ -0,0 +1,34 @@ +dist: trusty +language: php + +php: + - 7.0 + - 7.1 + +# This triggers builds to run on the new TravisCI infrastructure. +# See: http://docs.travis-ci.com/user/workers/container-based-infrastructure/ +sudo: false + +## Cache composer +cache: + directories: + - $HOME/.composer/cache + +#matrix: +# include: +# - php: 5.6 +# env: 'COMPOSER_FLAGS="--prefer-stable --prefer-lowest"' + +before_script: + - travis_retry composer update ${COMPOSER_FLAGS} --no-interaction --prefer-dist + +script: + - vendor/bin/phpcs --standard=psr2 src/ + - vendor/bin/phpunit --coverage-text --coverage-clover=coverage.clover + +after_script: + - | + if [[ "$TRAVIS_PHP_VERSION" != 'hhvm' && "$TRAVIS_PHP_VERSION" != '7.0' ]]; then + wget https://scrutinizer-ci.com/ocular.phar + php ocular.phar code-coverage:upload --format=php-clover coverage.clover + fi diff --git a/CHANGELOG.md b/CHANGELOG.md new file mode 100644 index 0000000..5c7e541 --- /dev/null +++ b/CHANGELOG.md @@ -0,0 +1,12 @@ +# Changelog + +All notable changes to `php-certificate-toolbox` will be documented in this file. + +Updates should follow the [Keep a CHANGELOG](http://keepachangelog.com/) principles. + +## 0.1.0 + +First release after major refactoring of [LEClient](https://github.com/yourivw/leclient) +to be composer installable and testable, as well as support for alternative storage +systems. + diff --git a/CODE_OF_CONDUCT.md b/CODE_OF_CONDUCT.md new file mode 100644 index 0000000..d994296 --- /dev/null +++ b/CODE_OF_CONDUCT.md @@ -0,0 +1,74 @@ +# Contributor Code of Conduct + +## Our Pledge + +In the interest of fostering an open and welcoming environment, we as +contributors and maintainers pledge to make participation in our project and +our community a harassment-free experience for everyone, regardless of age, body +size, disability, ethnicity, gender identity and expression, level of experience, +nationality, personal appearance, race, religion, or sexual identity and +orientation. + +## Our Standards + +Examples of behavior that contributes to creating a positive environment +include: + +* Using welcoming and inclusive language +* Being respectful of differing viewpoints and experiences +* Gracefully accepting constructive criticism +* Focusing on what is best for the community +* Showing empathy towards other community members + +Examples of unacceptable behavior by participants include: + +* The use of sexualized language or imagery and unwelcome sexual attention or +advances +* Trolling, insulting/derogatory comments, and personal or political attacks +* Public or private harassment +* Publishing others' private information, such as a physical or electronic + address, without explicit permission +* Other conduct which could reasonably be considered inappropriate in a + professional setting + +## Our Responsibilities + +Project maintainers are responsible for clarifying the standards of acceptable +behavior and are expected to take appropriate and fair corrective action in +response to any instances of unacceptable behavior. + +Project maintainers have the right and responsibility to remove, edit, or +reject comments, commits, code, wiki edits, issues, and other contributions +that are not aligned to this Code of Conduct, or to ban temporarily or +permanently any contributor for other behaviors that they deem inappropriate, +threatening, offensive, or harmful. + +## Scope + +This Code of Conduct applies both within project spaces and in public spaces +when an individual is representing the project or its community. Examples of +representing a project or community include using an official project e-mail +address, posting via an official social media account, or acting as an appointed +representative at an online or offline event. Representation of a project may be +further defined and clarified by project maintainers. + +## Enforcement + +Instances of abusive, harassing, or otherwise unacceptable behavior may be +reported by contacting the project team at `paul@elphin.com`. All +complaints will be reviewed and investigated and will result in a response that +is deemed necessary and appropriate to the circumstances. The project team is +obligated to maintain confidentiality with regard to the reporter of an incident. +Further details of specific enforcement policies may be posted separately. + +Project maintainers who do not follow or enforce the Code of Conduct in good +faith may face temporary or permanent repercussions as determined by other +members of the project's leadership. + +## Attribution + +This Code of Conduct is adapted from the [Contributor Covenant][homepage], version 1.4, +available at [http://contributor-covenant.org/version/1/4][version] + +[homepage]: http://contributor-covenant.org +[version]: http://contributor-covenant.org/version/1/4/ diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md new file mode 100644 index 0000000..672abac --- /dev/null +++ b/CONTRIBUTING.md @@ -0,0 +1,66 @@ +# Contributing + +Contributions are **welcome** and will be fully **credited**. This page details how to +contribute and the expected code quality for all contributions. + +## Pull Requests + +We accept contributions via Pull Requests on [Github](https://github.com/lordelph/php-certificate-toolbox). + +- **[PSR-2 Coding Standard](https://github.com/php-fig/fig-standards/blob/master/accepted/PSR-2-coding-style-guide.md)** - Check the code style with ``$ composer check-style`` and fix it with ``$ composer fix-style``. + +- **Add tests!** - Your patch won't be accepted if it doesn't have tests. + +- **Document any change in behaviour** - Make sure the `README.md` and any other relevant documentation are kept up-to-date. + +- **Consider our release cycle** - We try to follow [SemVer v2.0.0](http://semver.org/). Randomly breaking public APIs is not an option. + +- **Create feature branches** - Don't ask us to pull from your master branch. + +- **One pull request per feature** - If you want to do more than one thing, send multiple pull requests. + +- **Send coherent history** - Make sure each individual commit in your pull request is meaningful. If you had to make multiple intermediate commits while developing, please [squash them](http://www.git-scm.com/book/en/v2/Git-Tools-Rewriting-History#Changing-Multiple-Commit-Messages) before submitting. + + +## Running Tests + +``` bash +$ composer test +``` + +## Exceptions + +* All exceptions thrown by code in this package MUST implement `LEClientException` +* Custom exception classes SHOULD derive from standard base exceptions where appropriate +* a `LogicException` SHOULD be used for invalid use of methods or classses which would be + fixable by the developer using the classes +* a `RuntimeException` SHOULD be used for problems which arise from unexpected external + conditions, such as an ACME API failure. +* It is not necessary to add code coverage for runtime exceptions - such code paths SHOULD + be marked with `@codeCoverageIgnoreStart` / `@codeCoverageIgnoreEnd` markers + +## Logging + +The classes use a PSR-3 compatible logger. The following should be used as a guideline +for appropriate logging levels: + +* `debug` is for maintainer use only. If an end-user has an issue, they should be asked to + submit a report which contains a log with debug enabled. This should allow the interactions + with the remote ACME API to be observed. +* `info` should record a general interaction which an outside observer would find interesting, + typically, that a high level method of the main client class has been used. +* `notice` should record some expected change of state, e.g. a new order, new certificate etc +* `warning` should record an unusual but handled problem, e.g. regenerating a private key +* `error` should record an unusual but unhandled problem +* `critical` should record any logic problem where the problem is likely correctable by the + code using these classes. It will usually be followed by a `LogicException` +* `alert` should record unexpected issues arising from ACME API interactions, and will + generally be followed by a `RuntimeException` +* `emergency` should be used only when time is of the essence. This is not presently used + but one example might be failure to renew a certificate when an existing certificate is + known to be expiring soon + + + + +**Happy coding**! diff --git a/ISSUE_TEMPLATE.md b/ISSUE_TEMPLATE.md new file mode 100644 index 0000000..5b48c57 --- /dev/null +++ b/ISSUE_TEMPLATE.md @@ -0,0 +1,27 @@ + + +## Detailed description + +Provide a detailed description of the change or addition you are proposing. + +Make it clear if the issue is a bug, an enhancement or just a question. + +## Context + +Why is this change important to you? How would you use it? + +How can it benefit other users? + +## Possible implementation + +Not obligatory, but suggest an idea for implementing addition or change. + +## Your environment + +Include as many relevant details about the environment you experienced the bug in and how to reproduce it. + +* Version used (e.g. PHP 5.6, HHVM 3): +* Operating system and version (e.g. Ubuntu 16.04, Windows 7): +* Link to your project: +* ... +* ... diff --git a/LEClient/LEClient.php b/LEClient/LEClient.php deleted file mode 100644 index 3ea7e56..0000000 --- a/LEClient/LEClient.php +++ /dev/null @@ -1,200 +0,0 @@ - - * @copyright 2018 Youri van Weegberg - * @license https://opensource.org/licenses/mit-license.php MIT License - * @version 1.1.0 - * @link https://github.com/yourivw/LEClient - * @since Class available since Release 1.0.0 - */ -class LEClient -{ - const LE_PRODUCTION = 'https://acme-v02.api.letsencrypt.org'; - const LE_STAGING = 'https://acme-staging-v02.api.letsencrypt.org'; - - private $certificatesKeys; - private $accountKeys; - - private $connector; - private $account; - - private $log; - - const LOG_OFF = 0; // Logs no messages or faults, except Runtime Exceptions. - const LOG_STATUS = 1; // Logs only messages and faults. - const LOG_DEBUG = 2; // Logs messages, faults and raw responses from HTTP requests. - - /** - * Initiates the LetsEncrypt main client. - * - * @param array $email The array of strings containing e-mail addresses. Only used in this function when creating a new account. - * @param boolean $acmeURL ACME URL, can be string or one of predefined values: LE_STAGING or LE_PRODUCTION. Defaults to LE_STAGING. - * @param int $log The level of logging. Defaults to no logging. LOG_OFF, LOG_STATUS, LOG_DEBUG accepted. Defaults to LOG_OFF. (optional) - * @param string $certificateKeys The main directory in which all keys (and certificates), including account keys, are stored. Defaults to 'keys/'. (optional) - * @param array $certificateKeys Optional array containing location of all certificate files. Required paths are public_key, private_key, order and certificate/fullchain_certificate (you can use both or only one of them) - * @param string $accountKeys The directory in which the account keys are stored. Is a subdir inside $certificateKeys. Defaults to '__account/'.(optional) - * @param array $accountKeys Optional array containing location of account private and public keys. Required paths are private_key, public_key. - */ - public function __construct($email, $acmeURL = LEClient::LE_STAGING, $log = LEClient::LOG_OFF, $certificateKeys = 'keys/', $accountKeys = '__account/') - { - - $this->log = $log; - - if (is_bool($acmeURL)) - { - if ($acmeURL === true) $this->baseURL = LEClient::LE_STAGING; - elseif ($acmeURL === false) $this->baseURL = LEClient::LE_PRODUCTION; - } - elseif (is_string($acmeURL)) - { - $this->baseURL = $acmeURL; - } - else throw new \RuntimeException('acmeURL must be set to string or bool (legacy)'); - - if (is_array($certificateKeys) && is_string($accountKeys)) throw new \RuntimeException('when certificateKeys is array, accountKeys must be array also'); - elseif (is_array($accountKeys) && is_string($certificateKeys)) throw new \RuntimeException('when accountKeys is array, certificateKeys must be array also'); - - if (is_string($certificateKeys)) - { - - $certificateKeysDir = $certificateKeys; - - if(!file_exists($certificateKeys)) - { - mkdir($certificateKeys, 0777, true); - LEFunctions::createhtaccess($certificateKeys); - } - - $this->certificateKeys = array( - "public_key" => $certificateKeys.'/public.pem', - "private_key" => $certificateKeys.'/private.pem', - "certificate" => $certificateKeys.'/certificate.crt', - "fullchain_certificate" => $certificateKeys.'/fullchain.crt', - "order" => $certificateKeys.'/order' - ); - - } - elseif (is_array($certificateKeys)) - { - - if (!isset($certificateKeys['certificate']) && !isset($certificateKeys['fullchain_certificate'])) throw new \RuntimeException('certificateKeys[certificate] or certificateKeys[fullchain_certificate] file path must be set'); - if (!isset($certificateKeys['private_key'])) throw new \RuntimeException('certificateKeys[private_key] file path must be set'); - if (!isset($certificateKeys['order'])) $certificateKeys['order'] = dirname($certificateKeys['private_key']).'/order'; - if (!isset($certificateKeys['public_key'])) $certificateKeys['public_key'] = dirname($certificateKeys['private_key']).'/public.pem'; - - foreach ($certificateKeys as $param => $file) { - $parentDir = dirname($file); - if (!is_dir($parentDir)) throw new \RuntimeException($parentDir.' directory not found'); - } - - $this->certificateKeys = $certificateKeys; - - } - else - { - throw new \RuntimeException('certificateKeys must be string or array'); - } - - if (is_string($accountKeys)) - { - - $accountKeys = $certificateKeysDir.'/'.$accountKeys; - - if(!file_exists($accountKeys)) - { - mkdir($accountKeys, 0777, true); - LEFunctions::createhtaccess($accountKeys); - } - - $this->accountKeys = array( - "private_key" => $accountKeys.'/private.pem', - "public_key" => $accountKeys.'/public.pem' - ); - } - elseif (is_array($accountKeys)) - { - if (!isset($accountKeys['private_key'])) throw new \RuntimeException('accountKeys[private_key] file path must be set'); - if (!isset($accountKeys['public_key'])) throw new \RuntimeException('accountKeys[public_key] file path must be set'); - - foreach ($accountKeys as $param => $file) { - $parentDir = dirname($file); - if (!is_dir($parentDir)) throw new \RuntimeException($parentDir.' directory not found'); - } - - $this->accountKeys = $accountKeys; - } - else - { - throw new \RuntimeException('accountKeys must be string or array'); - } - - - $this->connector = new LEConnector($this->log, $this->baseURL, $this->accountKeys); - $this->account = new LEAccount($this->connector, $this->log, $email, $this->accountKeys); - if($this->log) LEFunctions::log('LEClient finished constructing', 'function LEClient __construct'); - } - - - /** - * Returns the LetsEncrypt account used in the current client. - * - * @return LEAccount The LetsEncrypt Account instance used by the client. - */ - public function getAccount() - { - return $this->account; - } - - /** - * Returns a LetsEncrypt order. If an order exists, this one is returned. If not, a new order is created and returned. - * - * @param string $basename The base name for the order. Preferable the top domain (example.org). Will be the directory in which the keys are stored. Used for the CommonName in the certificate as well. - * @param array $domains The array of strings containing the domain names on the certificate. - * @param string $keyType Type of the key we want to use for certificate. Can be provided in ALGO-SIZE format (ex. rsa-4096 or ec-256) or simple "rsa" and "ec" (using default sizes) - * @param string $notBefore A date string formatted like 0000-00-00T00:00:00Z (yyyy-mm-dd hh:mm:ss) at which the certificate becomes valid. Defaults to the moment the order is finalized. (optional) - * @param string $notAfter A date string formatted like 0000-00-00T00:00:00Z (yyyy-mm-dd hh:mm:ss) until which the certificate is valid. Defaults to 90 days past the moment the order is finalized. (optional) - * - * @return LEOrder The LetsEncrypt Order instance which is either retrieved or created. - */ - public function getOrCreateOrder($basename, $domains, $keyType = 'rsa-4096', $notBefore = '', $notAfter = '') - { - return new LEOrder($this->connector, $this->log, $this->certificateKeys, $basename, $domains, $keyType, $notBefore, $notAfter); - } -} -?> diff --git a/LEClient/src/LEAccount.php b/LEClient/src/LEAccount.php deleted file mode 100644 index bf1fe01..0000000 --- a/LEClient/src/LEAccount.php +++ /dev/null @@ -1,228 +0,0 @@ - - * @copyright 2018 Youri van Weegberg - * @license https://opensource.org/licenses/mit-license.php MIT License - * @version 1.1.0 - * @link https://github.com/yourivw/LEClient - * @since Class available since Release 1.0.0 - */ -class LEAccount -{ - private $connector; - private $accountKeys; - - public $id; - public $key; - public $contact; - public $agreement; - public $initialIp; - public $createdAt; - public $status; - - private $log; - - /** - * Initiates the LetsEncrypt Account class. - * - * @param LEConnector $connector The LetsEncrypt Connector instance to use for HTTP requests. - * @param int $log The level of logging. Defaults to no logging. LOG_OFF, LOG_STATUS, LOG_DEBUG accepted. - * @param array $email The array of strings containing e-mail addresses. Only used when creating a new account. - * @param array $accountKeys Array containing location of account keys files. - */ - public function __construct($connector, $log, $email, $accountKeys) - { - $this->connector = $connector; - $this->accountKeys = $accountKeys; - $this->log = $log; - - if(!file_exists($this->accountKeys['private_key']) OR !file_exists($this->accountKeys['public_key'])) - { - if($this->log >= LECLient::LOG_STATUS) LEFunctions::log('No account found, attempting to create account.', 'function LEAccount __construct'); - LEFunctions::RSAgenerateKeys(null, $this->accountKeys['private_key'], $this->accountKeys['public_key']); - $this->connector->accountURL = $this->createLEAccount($email); - } - else - { - $this->connector->accountURL = $this->getLEAccount(); - } - if($this->connector->accountURL == false) throw new \RuntimeException('Account not found or deactivated.'); - $this->getLEAccountData(); - } - - /** - * Creates a new LetsEncrypt account. - * - * @param array $email The array of strings containing e-mail addresses. - * - * @return object Returns the new account URL when the account was successfully created, false if not. - */ - private function createLEAccount($email) - { - $contact = array_map(function($addr) { return empty($addr) ? '' : (strpos($addr, 'mailto') === false ? 'mailto:' . $addr : $addr); }, $email); - - $sign = $this->connector->signRequestJWK(array('contact' => $contact, 'termsOfServiceAgreed' => true), $this->connector->newAccount); - $post = $this->connector->post($this->connector->newAccount, $sign); - if(strpos($post['header'], "201 Created") !== false) - { - if(preg_match('~Location: (\S+)~i', $post['header'], $matches)) return trim($matches[1]); - } - return false; - } - - /** - * Gets the LetsEncrypt account URL associated with the stored account keys. - * - * @return object Returns the account URL if it is found, or false when none is found. - */ - private function getLEAccount() - { - $sign = $this->connector->signRequestJWK(array('onlyReturnExisting' => true), $this->connector->newAccount); - $post = $this->connector->post($this->connector->newAccount, $sign); - - if(strpos($post['header'], "200 OK") !== false) - { - if(preg_match('~Location: (\S+)~i', $post['header'], $matches)) return trim($matches[1]); - } - return false; - } - - /** - * Gets the LetsEncrypt account data from the account URL. - */ - private function getLEAccountData() - { - $sign = $this->connector->signRequestKid(array('' => ''), $this->connector->accountURL, $this->connector->accountURL); - $post = $this->connector->post($this->connector->accountURL, $sign); - if(strpos($post['header'], "200 OK") !== false) - { - $this->id = $post['body']['id']; - $this->key = $post['body']['key']; - $this->contact = $post['body']['contact']; - $this->agreement = $post['body']['agreement']; - $this->initialIp = $post['body']['initialIp']; - $this->createdAt = $post['body']['createdAt']; - $this->status = $post['body']['status']; - } - else - { - throw new \RuntimeException('Account data cannot be found.'); - } - } - - /** - * Updates account data. Now just supporting new contact information. - * - * @param array $email The array of strings containing e-mail adresses. - * - * @return boolean Returns true if the update is successful, false if not. - */ - public function updateAccount($email) - { - $contact = array_map(function($addr) { return empty($addr) ? '' : (strpos($addr, 'mailto') === false ? 'mailto:' . $addr : $addr); }, $email); - - $sign = $this->connector->signRequestKid(array('contact' => $contact), $this->connector->accountURL, $this->connector->accountURL); - $post = $this->connector->post($this->connector->accountURL, $sign); - if(strpos($post['header'], "200 OK") !== false) - { - $this->id = $post['body']['id']; - $this->key = $post['body']['key']; - $this->contact = $post['body']['contact']; - $this->agreement = $post['body']['agreement']; - $this->initialIp = $post['body']['initialIp']; - $this->createdAt = $post['body']['createdAt']; - $this->status = $post['body']['status']; - if($this->log >= LECLient::LOG_STATUS) LEFunctions::log('Account data updated.', 'function updateAccount'); - return true; - } - else - { - return false; - } - } - - /** - * Creates new RSA account keys and updates the keys with LetsEncrypt. - * - * @return boolean Returns true if the update is successful, false if not. - */ - public function changeAccountKeys() - { - LEFunctions::RSAgenerateKeys(null, $this->accountKeys['private_key'].'.new', $this->accountKeys['public_key'].'.new'); - $privateKey = openssl_pkey_get_private(file_get_contents($this->accountKeys['private_key'].'.new')); - $details = openssl_pkey_get_details($privateKey); - $innerPayload = array('account' => $this->connector->accountURL, 'newKey' => array( - "kty" => "RSA", - "n" => LEFunctions::Base64UrlSafeEncode($details["rsa"]["n"]), - "e" => LEFunctions::Base64UrlSafeEncode($details["rsa"]["e"]) - )); - $outerPayload = $this->connector->signRequestJWK($innerPayload, $this->connector->keyChange, $this->accountKeys['private_key'].'.new'); - $sign = $this->connector->signRequestKid($outerPayload, $this->connector->accountURL, $this->connector->keyChange); - $post = $this->connector->post($this->connector->keyChange, $sign); - if(strpos($post['header'], "200 OK") !== false) - { - $this->getLEAccountData(); - - unlink($this->accountKeys['private_key']); - unlink($this->accountKeys['public_key']); - rename($this->accountKeys['private_key'].'.new', $this->accountKeys['private_key']); - rename($this->accountKeys['public_key'].'.new', $this->accountKeys['public_key']); - - if($this->log >= LECLient::LOG_STATUS) LEFunctions::log('Account keys changed.', 'function changeAccountKey'); - return true; - } - else - { - return false; - } - } - - /** - * Deactivates the LetsEncrypt account. - * - * @return boolean Returns true if the deactivation is successful, false if not. - */ - public function deactivateAccount() - { - $sign = $this->connector->signRequestKid(array('status' => 'deactivated'), $this->connector->accountURL, $this->connector->accountURL); - $post = $this->connector->post($this->connector->accountURL, $sign); - if(strpos($post['header'], "200 OK") !== false) - { - $this->connector->accountDeactivated = true; - if($this->log >= LECLient::LOG_STATUS) LEFunctions::log('Account deactivated.', 'function deactivateAccount'); - } - else - { - return false; - } - } -} - -?> diff --git a/LEClient/src/LEAuthorization.php b/LEClient/src/LEAuthorization.php deleted file mode 100644 index 7ea82a0..0000000 --- a/LEClient/src/LEAuthorization.php +++ /dev/null @@ -1,114 +0,0 @@ - - * @copyright 2018 Youri van Weegberg - * @license https://opensource.org/licenses/mit-license.php MIT License - * @version 1.1.0 - * @link https://github.com/yourivw/LEClient - * @since Class available since Release 1.0.0 - */ -class LEAuthorization -{ - private $connector; - - public $authorizationURL; - public $identifier; - public $status; - public $expires; - public $challenges; - - private $log; - - /** - * Initiates the LetsEncrypt Authorization class. Child of a LetsEncrypt Order instance. - * - * @param LEConnector $connector The LetsEncrypt Connector instance to use for HTTP requests. - * @param int $log The level of logging. Defaults to no logging. LOG_OFF, LOG_STATUS, LOG_DEBUG accepted. - * @param string $authorizationURL The URL of the authorization, given by a LetsEncrypt order request. - */ - public function __construct($connector, $log, $authorizationURL) - { - $this->connector = $connector; - $this->log = $log; - $this->authorizationURL = $authorizationURL; - - $get = $this->connector->get($this->authorizationURL); - if(strpos($get['header'], "200 OK") !== false) - { - $this->identifier = $get['body']['identifier']; - $this->status = $get['body']['status']; - $this->expires = $get['body']['expires']; - $this->challenges = $get['body']['challenges']; - } - else - { - if($this->log >= LECLient::LOG_STATUS) LEFunctions::log('Cannot find authorization \'' . $authorizationURL . '\'.', 'function LEAuthorization __construct'); - } - } - - /** - * Updates the data associated with the current LetsEncrypt Authorization instance. - */ - - public function updateData() - { - $get = $this->connector->get($this->authorizationURL); - if(strpos($get['header'], "200 OK") !== false) - { - $this->identifier = $get['body']['identifier']; - $this->status = $get['body']['status']; - $this->expires = $get['body']['expires']; - $this->challenges = $get['body']['challenges']; - } - else - { - if($this->log >= LECLient::LOG_STATUS) LEFunctions::log('Cannot find authorization \'' . $authorizationURL . '\'.', 'function updateData'); - } - } - - /** - * Gets the challenge of the given $type for this LetsEncrypt Authorization instance. Throws a Runtime Exception if the given $type is not found in this - * LetsEncrypt Authorization instance. - * - * @param int $type The type of verification. Supporting LEOrder::CHALLENGE_TYPE_HTTP and LEOrder::CHALLENGE_TYPE_DNS. - * - * @return array Returns an array with the challenge of the requested $type. - */ - public function getChallenge($type) - { - foreach($this->challenges as $challenge) - { - if($challenge['type'] == $type) return $challenge; - } - throw new \RuntimeException('No challenge found for type \'' . $type . '\' and identifier \'' . $this->identifier['value'] . '\'.'); - } -} - -?> \ No newline at end of file diff --git a/LEClient/src/LEConnector.php b/LEClient/src/LEConnector.php deleted file mode 100644 index 1bdc5fd..0000000 --- a/LEClient/src/LEConnector.php +++ /dev/null @@ -1,277 +0,0 @@ - - * @copyright 2018 Youri van Weegberg - * @license https://opensource.org/licenses/mit-license.php MIT License - * @version 1.1.0 - * @link https://github.com/yourivw/LEClient - * @since Class available since Release 1.0.0 - */ -class LEConnector -{ - public $baseURL; - public $accountKeys; - - private $nonce; - - public $keyChange; - public $newAccount; - public $newNonce; - public $newOrder; - public $revokeCert; - - public $accountURL; - public $accountDeactivated = false; - - private $log; - - /** - * Initiates the LetsEncrypt Connector class. - * - * @param int $log The level of logging. Defaults to no logging. LOG_OFF, LOG_STATUS, LOG_DEBUG accepted. - * @param string $baseURL The LetsEncrypt server URL to make requests to. - * @param array $accountKeys Array containing location of account keys files. - */ - public function __construct($log, $baseURL, $accountKeys) - { - $this->baseURL = $baseURL; - $this->accountKeys = $accountKeys; - $this->log = $log; - $this->getLEDirectory(); - $this->getNewNonce(); - } - - /** - * Requests the LetsEncrypt Directory and stores the necessary URLs in this LetsEncrypt Connector instance. - */ - private function getLEDirectory() - { - $req = $this->get('/directory'); - $this->keyChange = $req['body']['keyChange']; - $this->newAccount = $req['body']['newAccount']; - $this->newNonce = $req['body']['newNonce']; - $this->newOrder = $req['body']['newOrder']; - $this->revokeCert = $req['body']['revokeCert']; - } - - /** - * Requests a new nonce from the LetsEncrypt server and stores it in this LetsEncrypt Connector instance. - */ - private function getNewNonce() - { - if(strpos($this->head($this->newNonce)['header'], "204 No Content") == false) throw new \RuntimeException('No new nonce.'); - } - - /** - * Makes a Curl request. - * - * @param string $method The HTTP method to use. Accepting GET, POST and HEAD requests. - * @param string $URL The URL or partial URL to make the request to. If it is partial, the baseURL will be prepended. - * @param object $data The body to attach to a POST request. Expected as a JSON encoded string. - * - * @return array Returns an array with the keys 'request', 'header' and 'body'. - */ - private function request($method, $URL, $data = null) - { - if($this->accountDeactivated) throw new \RuntimeException('The account was deactivated. No further requests can be made.'); - - $headers = array('Accept: application/json', 'Content-Type: application/json'); - $requestURL = preg_match('~^http~', $URL) ? $URL : $this->baseURL . $URL; - $handle = curl_init(); - curl_setopt($handle, CURLOPT_URL, $requestURL); - curl_setopt($handle, CURLOPT_HTTPHEADER, $headers); - curl_setopt($handle, CURLOPT_RETURNTRANSFER, true); - curl_setopt($handle, CURLOPT_HEADER, true); - - switch ($method) { - case 'GET': - break; - case 'POST': - curl_setopt($handle, CURLOPT_POST, true); - curl_setopt($handle, CURLOPT_POSTFIELDS, $data); - break; - case 'HEAD': - curl_setopt($handle, CURLOPT_CUSTOMREQUEST, 'HEAD'); - curl_setopt($handle, CURLOPT_NOBODY, true); - break; - default: - throw new \RuntimeException('HTTP request ' . $method . ' not supported.'); - break; - } - $response = curl_exec($handle); - - if(curl_errno($handle)) { - throw new \RuntimeException('Curl: ' . curl_error($handle)); - } - - $header_size = curl_getinfo($handle, CURLINFO_HEADER_SIZE); - - $header = substr($response, 0, $header_size); - $body = substr($response, $header_size); - $jsonbody = json_decode($body, true); - $jsonresponse = array('request' => $method . ' ' . $requestURL, 'header' => $header, 'body' => $jsonbody === null ? $body : $jsonbody); - if($this->log >= LECLient::LOG_DEBUG) LEFunctions::log($jsonresponse); - - if( (($method == 'POST' OR $method == 'GET') AND strpos($header, "200 OK") === false AND strpos($header, "201 Created") === false) OR - ($method == 'HEAD' AND strpos($header, "204 No Content") === false)) - { - throw new \RuntimeException('Invalid response, header: ' . $header); - } - - if(preg_match('~Replay\-Nonce: (\S+)~i', $header, $matches)) - { - $this->nonce = trim($matches[1]); - } - else - { - if($method == 'POST') $this->getNewNonce(); // Not expecting a new nonce with GET and HEAD requests. - } - - return $jsonresponse; - } - - /** - * Makes a GET request. - * - * @param string $url The URL or partial URL to make the request to. If it is partial, the baseURL will be prepended. - * - * @return array Returns an array with the keys 'request', 'header' and 'body'. - */ - public function get($url) - { - return $this->request('GET', $url); - } - - /** - * Makes a POST request. - * - * @param string $url The URL or partial URL to make the request to. If it is partial, the baseURL will be prepended. - * @param object $data The body to attach to a POST request. Expected as a json string. - * - * @return array Returns an array with the keys 'request', 'header' and 'body'. - */ - public function post($url, $data = null) - { - return $this->request('POST', $url, $data); - } - - /** - * Makes a HEAD request. - * - * @param string $url The URL or partial URL to make the request to. If it is partial, the baseURL will be prepended. - * - * @return array Returns an array with the keys 'request', 'header' and 'body'. - */ - public function head($url) - { - return $this->request('HEAD', $url); - } - - /** - * Generates a JSON Web Key signature to attach to the request. - * - * @param array $payload The payload to add to the signature. - * @param string $url The URL to use in the signature. - * @param string $privateKeyFile The private key to sign the request with. Defaults to 'private.pem'. Defaults to accountKeys[private_key]. - * - * @return string Returns a JSON encoded string containing the signature. - */ - public function signRequestJWK($payload, $url, $privateKeyFile = '') - { - if($privateKeyFile == '') $privateKeyFile = $this->accountKeys['private_key']; - $privateKey = openssl_pkey_get_private(file_get_contents($privateKeyFile)); - $details = openssl_pkey_get_details($privateKey); - - $protected = array( - "alg" => "RS256", - "jwk" => array( - "kty" => "RSA", - "n" => LEFunctions::Base64UrlSafeEncode($details["rsa"]["n"]), - "e" => LEFunctions::Base64UrlSafeEncode($details["rsa"]["e"]), - ), - "nonce" => $this->nonce, - "url" => $url - ); - - $payload64 = LEFunctions::Base64UrlSafeEncode(str_replace('\\/', '/', is_array($payload) ? json_encode($payload) : $payload)); - $protected64 = LEFunctions::Base64UrlSafeEncode(json_encode($protected)); - - openssl_sign($protected64.'.'.$payload64, $signed, $privateKey, "SHA256"); - $signed64 = LEFunctions::Base64UrlSafeEncode($signed); - - $data = array( - 'protected' => $protected64, - 'payload' => $payload64, - 'signature' => $signed64 - ); - - return json_encode($data); - } - - /** - * Generates a Key ID signature to attach to the request. - * - * @param array $payload The payload to add to the signature. - * @param string $kid The Key ID to use in the signature. - * @param string $url The URL to use in the signature. - * @param string $privateKeyFile The private key to sign the request with. Defaults to 'private.pem'. Defaults to accountKeys[private_key]. - * - * @return string Returns a JSON encoded string containing the signature. - */ - public function signRequestKid($payload, $kid, $url, $privateKeyFile = '') - { - if($privateKeyFile == '') $privateKeyFile = $this->accountKeys['private_key']; - $privateKey = openssl_pkey_get_private(file_get_contents($privateKeyFile)); - $details = openssl_pkey_get_details($privateKey); - - $protected = array( - "alg" => "RS256", - "kid" => $kid, - "nonce" => $this->nonce, - "url" => $url - ); - - $payload64 = LEFunctions::Base64UrlSafeEncode(str_replace('\\/', '/', is_array($payload) ? json_encode($payload) : $payload)); - $protected64 = LEFunctions::Base64UrlSafeEncode(json_encode($protected)); - - openssl_sign($protected64.'.'.$payload64, $signed, $privateKey, "SHA256"); - $signed64 = LEFunctions::Base64UrlSafeEncode($signed); - - $data = array( - 'protected' => $protected64, - 'payload' => $payload64, - 'signature' => $signed64 - ); - - return json_encode($data); - } -} - -?> diff --git a/LEClient/src/LEFunctions.php b/LEClient/src/LEFunctions.php deleted file mode 100644 index 7e59dda..0000000 --- a/LEClient/src/LEFunctions.php +++ /dev/null @@ -1,233 +0,0 @@ - - * @copyright 2018 Youri van Weegberg - * @license https://opensource.org/licenses/mit-license.php MIT License - * @version 1.1.0 - * @link https://github.com/yourivw/LEClient - * @since Class available since Release 1.0.0 - */ -class LEFunctions -{ - /** - * Generates a new RSA keypair and saves both keys to a new file. - * - * @param string $directory The directory in which to store the new keys. If set to null or empty string - privateKeyFile and publicKeyFile will be treated as absolute paths. - * @param string $privateKeyFile The filename for the private key file. - * @param string $publicKeyFile The filename for the public key file. - * @param string $keySize RSA key size, must be between 2048 and 4096 (default is 4096) - */ - public static function RSAGenerateKeys($directory, $privateKeyFile = 'private.pem', $publicKeyFile = 'public.pem', $keySize = 4096) - { - - if ($keySize < 2048 || $keySize > 4096) throw new \RuntimeException("RSA key size must be between 2048 and 4096"); - - $res = openssl_pkey_new(array( - "private_key_type" => OPENSSL_KEYTYPE_RSA, - "private_key_bits" => intval($keySize), - )); - - if(!openssl_pkey_export($res, $privateKey)) throw new \RuntimeException("RSA keypair export failed!"); - - $details = openssl_pkey_get_details($res); - - if ($directory !== null && $directory !== '') - { - $privateKeyFile = $directory.$privateKeyFile; - $publicKeyFile = $directory.$publicKeyFile; - } - - file_put_contents($privateKeyFile, $privateKey); - file_put_contents($publicKeyFile, $details['key']); - - openssl_pkey_free($res); - } - - - - /** - * Generates a new EC prime256v1 keypair and saves both keys to a new file. - * - * @param string $directory The directory in which to store the new keys. If set to null or empty string - privateKeyFile and publicKeyFile will be treated as absolute paths. - * @param string $privateKeyFile The filename for the private key file. - * @param string $publicKeyFile The filename for the public key file. - * @param string $keysize EC key size, possible values are 256 (prime256v1) or 384 (secp384r1), default is 256 - */ - public static function ECGenerateKeys($directory, $privateKeyFile = 'private.pem', $publicKeyFile = 'public.pem', $keySize = 256) - { - if (version_compare(PHP_VERSION, '7.1.0') == -1) throw new \RuntimeException("PHP 7.1+ required for EC keys"); - - - if ($keySize == 256) - { - $res = openssl_pkey_new(array( - "private_key_type" => OPENSSL_KEYTYPE_EC, - "curve_name" => "prime256v1", - )); - } - elseif ($keySize == 384) - { - $res = openssl_pkey_new(array( - "private_key_type" => OPENSSL_KEYTYPE_EC, - "curve_name" => "secp384r1", - )); - } - else throw new \RuntimeException("EC key size must be 256 or 384"); - - - if(!openssl_pkey_export($res, $privateKey)) throw new \RuntimeException("EC keypair export failed!"); - - $details = openssl_pkey_get_details($res); - - if ($directory !== null && $directory !== '') - { - $privateKeyFile = $directory.$privateKeyFile; - $publicKeyFile = $directory.$publicKeyFile; - } - - file_put_contents($privateKeyFile, $privateKey); - file_put_contents($publicKeyFile, $details['key']); - - openssl_pkey_free($res); - } - - - - /** - * Encodes a string input to a base64 encoded string which is URL safe. - * - * @param string $input The input string to encode. - * - * @return string Returns a URL safe base64 encoded string. - */ - public static function Base64UrlSafeEncode($input) - { - return str_replace('=', '', strtr(base64_encode($input), '+/', '-_')); - } - - /** - * Decodes a string that is URL safe base64 encoded. - * - * @param string $input The encoded input string to decode. - * - * @return string Returns the decoded input string. - */ - public static function Base64UrlSafeDecode($input) - { - $remainder = strlen($input) % 4; - if ($remainder) { - $padlen = 4 - $remainder; - $input .= str_repeat('=', $padlen); - } - return base64_decode(strtr($input, '-_', '+/')); - } - - - - /** - * Outputs a log message. - * - * @param object $data The data to print. - * @param string $function The function name to print above. Defaults to the calling function's name from the stacktrace. (optional) - */ - public static function log($data, $function = '') - { - $e = new Exception(); - $trace = $e->getTrace(); - $function = $function == '' ? 'function ' . $trace[3]['function'] . ' (function ' . $trace[2]['function'] . ')' : $function; - if (PHP_SAPI == "cli") - { - echo '[' . date('d-m-Y H:i:s') . '] ' . $function . ":\n"; - print_r($data); - echo "\n\n"; - } - else - { - echo '' . date('d-m-Y H:i:s') . ', ' . $function . ':
'; - print_r($data); - echo '

'; - } - } - - - - /** - * Makes a request to the HTTP challenge URL and checks whether the authorization is valid for the given $domain. - * - * @param string $domain The domain to check the authorization for. - * @param string $token The token (filename) to request. - * @param string $keyAuthorization the keyAuthorization (file content) to compare. - * - * @return boolean Returns true if the challenge is valid, false if not. - */ - public static function checkHTTPChallenge($domain, $token, $keyAuthorization) - { - $requestURL = $domain . '/.well-known/acme-challenge/' . $token; - $handle = curl_init(); - curl_setopt($handle, CURLOPT_URL, $requestURL); - curl_setopt($handle, CURLOPT_RETURNTRANSFER, true); - curl_setopt($handle, CURLOPT_FOLLOWLOCATION, true); - $response = curl_exec($handle); - return (!empty($response) && $response == $keyAuthorization); - } - - /** - * Checks whether the applicable DNS TXT record is a valid authorization for the given $domain. - * - * @param string $domain The domain to check the authorization for. - * @param string $DNSDigest The digest to compare the DNS record to. - * - * @return boolean Returns true if the challenge is valid, false if not. - */ - public static function checkDNSChallenge($domain, $DNSDigest) - { - $DNS = '_acme-challenge.' . str_replace('*.', '', $domain); - $records = dns_get_record($DNS, DNS_TXT); - foreach($records as $record) - { - if($record['host'] == $DNS && $record['type'] == 'TXT' && $record['txt'] == $DNSDigest) return true; - } - return false; - } - - - - /** - * Creates a simple .htaccess file in $directory which denies from all. - * - * @param string $directory The directory in which to put the .htaccess file. - */ - public static function createhtaccess($directory) - { - file_put_contents($directory . '.htaccess', "order deny,allow\ndeny from all"); - } -} - -?> diff --git a/LEClient/src/LEOrder.php b/LEClient/src/LEOrder.php deleted file mode 100644 index bc235f0..0000000 --- a/LEClient/src/LEOrder.php +++ /dev/null @@ -1,657 +0,0 @@ - - * @copyright 2018 Youri van Weegberg - * @license https://opensource.org/licenses/mit-license.php MIT License - * @version 1.1.0 - * @link https://github.com/yourivw/LEClient - * @since Class available since Release 1.0.0 - */ -class LEOrder -{ - private $connector; - - private $basename; - private $certificateKeys; - private $orderURL; - private $keyType; - private $keySize; - - public $status; - public $expires; - public $identifiers; - private $authorizationURLs; - public $authorizations; - public $finalizeURL; - public $certificateURL; - - private $log; - - - const CHALLENGE_TYPE_HTTP = 'http-01'; - const CHALLENGE_TYPE_DNS = 'dns-01'; - - /** - * Initiates the LetsEncrypt Order class. If the base name is found in the $keysDir directory, the order data is requested. If no order was found locally, if the request is invalid or when there is a change in domain names, a new order is created. - * - * @param LEConnector $connector The LetsEncrypt Connector instance to use for HTTP requests. - * @param int $log The level of logging. Defaults to no logging. LOG_OFF, LOG_STATUS, LOG_DEBUG accepted. - * @param array $certificateKeys Array containing location of certificate keys files. - * @param string $basename The base name for the order. Preferable the top domain (example.org). Will be the directory in which the keys are stored. Used for the CommonName in the certificate as well. - * @param array $domains The array of strings containing the domain names on the certificate. - * @param string $keyType Type of the key we want to use for certificate. Can be provided in ALGO-SIZE format (ex. rsa-4096 or ec-256) or simple "rsa" and "ec" (using default sizes) - * @param string $notBefore A date string formatted like 0000-00-00T00:00:00Z (yyyy-mm-dd hh:mm:ss) at which the certificate becomes valid. - * @param string $notAfter A date string formatted like 0000-00-00T00:00:00Z (yyyy-mm-dd hh:mm:ss) until which the certificate is valid. - */ - public function __construct($connector, $log, $certificateKeys, $basename, $domains, $keyType = 'rsa-4096', $notBefore, $notAfter) - { - $this->connector = $connector; - $this->basename = $basename; - $this->log = $log; - - if ($keyType == 'rsa') - { - $this->keyType = 'rsa'; - $this->keySize = 4096; - } - elseif ($keyType == 'ec') - { - $this->keyType = 'ec'; - $this->keySize = 256; - } - else - { - preg_match_all('/^(rsa|ec)\-([0-9]{3,4})$/', $keyType, $keyTypeParts, PREG_SET_ORDER, 0); - - if (!empty($keyTypeParts)) - { - $this->keyType = $keyTypeParts[0][1]; - $this->keySize = intval($keyTypeParts[0][2]); - } - else throw new \RuntimeException('Key type \'' . $keyType . '\' not supported.'); - } - - $this->certificateKeys = $certificateKeys; - - if(file_exists($this->certificateKeys['private_key']) AND file_exists($this->certificateKeys['order']) AND file_exists($this->certificateKeys['public_key'])) - { - $this->orderURL = file_get_contents($this->certificateKeys['order']); - if (filter_var($this->orderURL, FILTER_VALIDATE_URL)) - { - $get = $this->connector->get($this->orderURL); - if(strpos($get['header'], "200 OK") !== false) - { - $orderdomains = array_map(function($ident) { return $ident['value']; }, $get['body']['identifiers']); - $diff = array_merge(array_diff($orderdomains, $domains), array_diff($domains, $orderdomains)); - if(!empty($diff)) - { - foreach ($this->certificateKeys as $file) - { - if (is_file($file)) rename($file, $file.'.old'); - } - if($this->log >= LECLient::LOG_STATUS) LEFunctions::log('Domains do not match order data. Renaming current files and creating new order.', 'function LEOrder __construct'); - $this->createOrder($domains, $notBefore, $notAfter, $keyType); - } - else - { - $this->status = $get['body']['status']; - $this->expires = $get['body']['expires']; - $this->identifiers = $get['body']['identifiers']; - $this->authorizationURLs = $get['body']['authorizations']; - $this->finalizeURL = $get['body']['finalize']; - if(array_key_exists('certificate', $get['body'])) $this->certificateURL = $get['body']['certificate']; - $this->updateAuthorizations(); - } - } - else - { - foreach ($this->certificateKeys as $file) - { - if (is_file($file)) unlink($file); - } - if($this->log >= LECLient::LOG_STATUS) LEFunctions::log('Order data for \'' . $this->basename . '\' invalid. Deleting order data and creating new order.', 'function LEOrder __construct'); - $this->createOrder($domains, $notBefore, $notAfter); - } - } - else - { - - foreach ($this->certificateKeys as $file) - { - if (is_file($file)) unlink($file); - } - if($this->log >= LECLient::LOG_STATUS) LEFunctions::log('Order data for \'' . $this->basename . '\' invalid. Deleting order data and creating new order.', 'function LEOrder __construct'); - - $this->createOrder($domains, $notBefore, $notAfter); - } - } - else - { - if($this->log >= LECLient::LOG_STATUS) LEFunctions::log('No order found for \'' . $this->basename . '\'. Creating new order.', 'function LEOrder __construct'); - $this->createOrder($domains, $notBefore, $notAfter); - } - } - - /** - * Creates a new LetsEncrypt order and fills this instance with its data. Subsequently creates a new RSA keypair for the certificate. - * - * @param array $domains The array of strings containing the domain names on the certificate. - * @param string $notBefore A date string formatted like 0000-00-00T00:00:00Z (yyyy-mm-dd hh:mm:ss) at which the certificate becomes valid. - * @param string $notAfter A date string formatted like 0000-00-00T00:00:00Z (yyyy-mm-dd hh:mm:ss) until which the certificate is valid. - */ - private function createOrder($domains, $notBefore, $notAfter) - { - if(preg_match('~(\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}Z|^$)~', $notBefore) AND preg_match('~(\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}Z|^$)~', $notAfter)) - { - - $dns = array(); - foreach($domains as $domain) - { - if(preg_match_all('~(\*\.)~', $domain) > 1) throw new \RuntimeException('Cannot create orders with multiple wildcards in one domain.'); - $dns[] = array('type' => 'dns', 'value' => $domain); - } - $payload = array("identifiers" => $dns, 'notBefore' => $notBefore, 'notAfter' => $notAfter); - $sign = $this->connector->signRequestKid($payload, $this->connector->accountURL, $this->connector->newOrder); - $post = $this->connector->post($this->connector->newOrder, $sign); - - if(strpos($post['header'], "201 Created") !== false) - { - if(preg_match('~Location: (\S+)~i', $post['header'], $matches)) - { - $this->orderURL = trim($matches[1]); - file_put_contents($this->certificateKeys['order'], $this->orderURL); - if ($this->keyType == "rsa") - { - LEFunctions::RSAgenerateKeys(null, $this->certificateKeys['private_key'], $this->certificateKeys['public_key'], $this->keySize); - } - elseif ($this->keyType == "ec") - { - LEFunctions::ECgenerateKeys(null, $this->certificateKeys['private_key'], $this->certificateKeys['public_key'], $this->keySize); - } - else - { - throw new \RuntimeException('Key type \'' . $this->keyType . '\' not supported.'); - } - - $this->status = $post['body']['status']; - $this->expires = $post['body']['expires']; - $this->identifiers = $post['body']['identifiers']; - $this->authorizationURLs = $post['body']['authorizations']; - $this->finalizeURL = $post['body']['finalize']; - if(array_key_exists('certificate', $post['body'])) $this->certificateURL = $post['body']['certificate']; - $this->updateAuthorizations(); - - if($this->log >= LECLient::LOG_STATUS) LEFunctions::log('Created order for \'' . $this->basename . '\'.', 'function createOrder (function LEOrder __construct)'); - } - else - { - throw new \RuntimeException('New-order returned invalid response.'); - } - } - else - { - throw new \RuntimeException('Creating new order failed.'); - } - } - else - { - throw new \RuntimeException('notBefore and notAfter fields must be empty or be a string similar to 0000-00-00T00:00:00Z'); - } - } - - /** - * Fetches the latest data concerning this LetsEncrypt Order instance and fills this instance with the new data. - */ - private function updateOrderData() - { - $get = $this->connector->get($this->orderURL); - if(strpos($get['header'], "200 OK") !== false) - { - $this->status = $get['body']['status']; - $this->expires = $get['body']['expires']; - $this->identifiers = $get['body']['identifiers']; - $this->authorizationURLs = $get['body']['authorizations']; - $this->finalizeURL = $get['body']['finalize']; - if(array_key_exists('certificate', $get['body'])) $this->certificateURL = $get['body']['certificate']; - $this->updateAuthorizations(); - } - else - { - if($this->log >= LECLient::LOG_STATUS) LEFunctions::log('Cannot update data for order \'' . $this->basename . '\'.', 'function updateOrderData'); - } - } - - /** - * Fetches the latest data concerning all authorizations connected to this LetsEncrypt Order instance and creates and stores a new LetsEncrypt Authorization instance for each one. - */ - private function updateAuthorizations() - { - $this->authorizations = array(); - foreach($this->authorizationURLs as $authURL) - { - if (filter_var($authURL, FILTER_VALIDATE_URL)) - { - $auth = new LEAuthorization($this->connector, $this->log, $authURL); - if($auth != false) $this->authorizations[] = $auth; - } - } - } - - /** - * Walks all LetsEncrypt Authorization instances and returns whether they are all valid (verified). - * - * @return boolean Returns true if all authorizations are valid (verified), returns false if not. - */ - public function allAuthorizationsValid() - { - if(count($this->authorizations) > 0) - { - foreach($this->authorizations as $auth) - { - if($auth->status != 'valid') return false; - } - return true; - } - return false; - } - - /** - * Get all pending LetsEncrypt Authorization instances and return the necessary data for verification. The data in the return object depends on the $type. - * - * @param int $type The type of verification to get. Supporting http-01 and dns-01. Supporting LEOrder::CHALLENGE_TYPE_HTTP and LEOrder::CHALLENGE_TYPE_DNS. Throws - * a Runtime Exception when requesting an unknown $type. Keep in mind a wildcard domain authorization only accepts LEOrder::CHALLENGE_TYPE_DNS. - * - * @return object Returns an array with verification data if successful, false if not pending LetsEncrypt Authorization instances were found. The return array always - * contains 'type' and 'identifier'. For LEOrder::CHALLENGE_TYPE_HTTP, the array contains 'filename' and 'content' for necessary the authorization file. - * For LEOrder::CHALLENGE_TYPE_DNS, the array contains 'DNSDigest', which is the content for the necessary DNS TXT entry. - */ - - public function getPendingAuthorizations($type) - { - $authorizations = array(); - - $privateKey = openssl_pkey_get_private(file_get_contents($this->connector->accountKeys['private_key'])); - $details = openssl_pkey_get_details($privateKey); - - $header = array( - "e" => LEFunctions::Base64UrlSafeEncode($details["rsa"]["e"]), - "kty" => "RSA", - "n" => LEFunctions::Base64UrlSafeEncode($details["rsa"]["n"]) - - ); - $digest = LEFunctions::Base64UrlSafeEncode(hash('sha256', json_encode($header), true)); - - foreach($this->authorizations as $auth) - { - if($auth->status == 'pending') - { - $challenge = $auth->getChallenge($type); - if($challenge['status'] == 'pending') - { - $keyAuthorization = $challenge['token'] . '.' . $digest; - switch(strtolower($type)) - { - case LEOrder::CHALLENGE_TYPE_HTTP: - $authorizations[] = array('type' => LEOrder::CHALLENGE_TYPE_HTTP, 'identifier' => $auth->identifier['value'], 'filename' => $challenge['token'], 'content' => $keyAuthorization); - break; - case LEOrder::CHALLENGE_TYPE_DNS: - $DNSDigest = LEFunctions::Base64UrlSafeEncode(hash('sha256', $keyAuthorization, true)); - $authorizations[] = array('type' => LEOrder::CHALLENGE_TYPE_DNS, 'identifier' => $auth->identifier['value'], 'DNSDigest' => $DNSDigest); - break; - } - } - } - } - - return count($authorizations) > 0 ? $authorizations : false; - } - - /** - * Sends a verification request for a given $identifier and $type. The function itself checks whether the verification is valid before making the request. - * Updates the LetsEncrypt Authorization instances after a successful verification. - * - * @param string $identifier The domain name to verify. - * @param int $type The type of verification. Supporting LEOrder::CHALLENGE_TYPE_HTTP and LEOrder::CHALLENGE_TYPE_DNS. - * - * @return boolean Returns true when the verification request was successful, false if not. - */ - public function verifyPendingOrderAuthorization($identifier, $type) - { - $privateKey = openssl_pkey_get_private(file_get_contents($this->connector->accountKeys['private_key'])); - $details = openssl_pkey_get_details($privateKey); - - $header = array( - "e" => LEFunctions::Base64UrlSafeEncode($details["rsa"]["e"]), - "kty" => "RSA", - "n" => LEFunctions::Base64UrlSafeEncode($details["rsa"]["n"]) - - ); - $digest = LEFunctions::Base64UrlSafeEncode(hash('sha256', json_encode($header), true)); - - foreach($this->authorizations as $auth) - { - if($auth->identifier['value'] == $identifier) - { - if($auth->status == 'pending') - { - $challenge = $auth->getChallenge($type); - if($challenge['status'] == 'pending') - { - $keyAuthorization = $challenge['token'] . '.' . $digest; - switch($type) - { - case LEOrder::CHALLENGE_TYPE_HTTP: - if(LEFunctions::checkHTTPChallenge($identifier, $challenge['token'], $keyAuthorization)) - { - $sign = $this->connector->signRequestKid(array('keyAuthorization' => $keyAuthorization), $this->connector->accountURL, $challenge['url']); - $post = $this->connector->post($challenge['url'], $sign); - if(strpos($post['header'], "200 OK") !== false) - { - if($this->log >= LECLient::LOG_STATUS) LEFunctions::log('HTTP challenge for \'' . $identifier . '\' valid.', 'function verifyPendingOrderAuthorization'); - while($auth->status == 'pending') - { - sleep(1); - $auth->updateData(); - } - return true; - } - } - else - { - if($this->log >= LECLient::LOG_STATUS) LEFunctions::log('HTTP challenge for \'' . $identifier . '\' tested, found invalid.', 'function verifyPendingOrderAuthorization'); - } - break; - case LEOrder::CHALLENGE_TYPE_DNS: - $DNSDigest = LEFunctions::Base64UrlSafeEncode(hash('sha256', $keyAuthorization, true)); - if(LEFunctions::checkDNSChallenge($identifier, $DNSDigest)) - { - $sign = $this->connector->signRequestKid(array('keyAuthorization' => $keyAuthorization), $this->connector->accountURL, $challenge['url']); - $post = $this->connector->post($challenge['url'], $sign); - if(strpos($post['header'], "200 OK") !== false) - { - if($this->log >= LECLient::LOG_STATUS) LEFunctions::log('DNS challenge for \'' . $identifier . '\' valid.', 'function verifyPendingOrderAuthorization'); - while($auth->status == 'pending') - { - sleep(1); - $auth->updateData(); - } - return true; - } - } - else - { - if($this->log >= LECLient::LOG_STATUS) LEFunctions::log('DNS challenge for \'' . $identifier . '\' tested, found invalid.', 'function verifyPendingOrderAuthorization'); - } - break; - } - } - } - } - } - return false; - } - - /** - * Deactivate an LetsEncrypt Authorization instance. - * - * @param string $identifier The domain name for which the verification should be deactivated. - * - * @return boolean Returns true is the deactivation request was successful, false if not. - */ - public function deactivateOrderAuthorization($identifier) - { - foreach($this->authorizations as $auth) - { - if($auth->identifier['value'] == $identifier) - { - $sign = $this->connector->signRequestKid(array('status' => 'deactivated'), $this->connector->accountURL, $auth->authorizationURL); - $post = $this->connector->post($auth->authorizationURL, $sign); - if(strpos($post['header'], "200 OK") !== false) - { - if($this->log >= LECLient::LOG_STATUS) LEFunctions::log('Authorization for \'' . $identifier . '\' deactivated.', 'function deactivateOrderAuthorization'); - $this->updateAuthorizations(); - return true; - } - } - } - if($this->log >= LECLient::LOG_STATUS) LEFunctions::log('No authorization found for \'' . $identifier . '\', cannot deactivate.', 'function deactivateOrderAuthorization'); - return false; - } - - /** - * Generates a Certificate Signing Request for the identifiers in the current LetsEncrypt Order instance. If possible, the base name will be the certificate - * common name and all domain names in this LetsEncrypt Order instance will be added to the Subject Alternative Names entry. - * - * @return string Returns the generated CSR as string, unprepared for LetsEncrypt. Preparation for the request happens in finalizeOrder() - */ - public function generateCSR() - { - $domains = array_map(function ($dns) { return $dns['value']; }, $this->identifiers); - if(in_array($this->basename, $domains)) - { - $CN = $this->basename; - } - elseif(in_array('*.' . $this->basename, $domains)) - { - $CN = '*.' . $this->basename; - } - else - { - $CN = $domains[0]; - } - - $dn = array( - "commonName" => $CN - ); - - $san = implode(",", array_map(function ($dns) { - return "DNS:" . $dns; - }, $domains)); - $tmpConf = tmpfile(); - $tmpConfMeta = stream_get_meta_data($tmpConf); - $tmpConfPath = $tmpConfMeta["uri"]; - - fwrite($tmpConf, - 'HOME = . - RANDFILE = $ENV::HOME/.rnd - [ req ] - default_bits = 4096 - default_keyfile = privkey.pem - distinguished_name = req_distinguished_name - req_extensions = v3_req - [ req_distinguished_name ] - countryName = Country Name (2 letter code) - [ v3_req ] - basicConstraints = CA:FALSE - subjectAltName = ' . $san . ' - keyUsage = nonRepudiation, digitalSignature, keyEncipherment'); - - $privateKey = openssl_pkey_get_private(file_get_contents($this->certificateKeys['private_key'])); - $csr = openssl_csr_new($dn, $privateKey, array('config' => $tmpConfPath, 'digest_alg' => 'sha256')); - openssl_csr_export ($csr, $csr); - return $csr; - } - - /** - * Checks, for redundancy, whether all authorizations are valid, and finalizes the order. Updates this LetsEncrypt Order instance with the new data. - * - * @param string $csr The Certificate Signing Request as a string. Can be a custom CSR. If empty, a CSR will be generated with the generateCSR() function. - * - * @return boolean Returns true if the finalize request was successful, false if not. - */ - public function finalizeOrder($csr = '') - { - if($this->status == 'pending') - { - if($this->allAuthorizationsValid()) - { - if(empty($csr)) $csr = $this->generateCSR(); - if(preg_match('~-----BEGIN\sCERTIFICATE\sREQUEST-----(.*)-----END\sCERTIFICATE\sREQUEST-----~s', $csr, $matches)) $csr = $matches[1]; - $csr = trim(LEFunctions::Base64UrlSafeEncode(base64_decode($csr))); - $sign = $this->connector->signRequestKid(array('csr' => $csr), $this->connector->accountURL, $this->finalizeURL); - $post = $this->connector->post($this->finalizeURL, $sign); - if(strpos($post['header'], "200 OK") !== false) - { - $this->status = $post['body']['status']; - $this->expires = $post['body']['expires']; - $this->identifiers = $post['body']['identifiers']; - $this->authorizationURLs = $post['body']['authorizations']; - $this->finalizeURL = $post['body']['finalize']; - if(array_key_exists('certificate', $post['body'])) $this->certificateURL = $post['body']['certificate']; - $this->updateAuthorizations(); - if($this->log >= LECLient::LOG_STATUS) LEFunctions::log('Order for \'' . $this->basename . '\' finalized.', 'function finalizeOrder'); - return true; - } - } - else - { - if($this->log >= LECLient::LOG_STATUS) LEFunctions::log('Not all authorizations are valid for \'' . $this->basename . '\'. Cannot finalize order.', 'function finalizeOrder'); - } - } - else - { - if($this->log >= LECLient::LOG_STATUS) LEFunctions::log('Order status for \'' . $this->basename . '\' is \'' . $this->status . '\'. Cannot finalize order.', 'function finalizeOrder'); - } - return false; - } - - /** - * Gets whether the LetsEncrypt Order is finalized by checking whether the status is processing or valid. Keep in mind, a certificate is not yet available when the status still is processing. - * - * @return boolean Returns true if finalized, false if not. - */ - public function isFinalized() - { - return ($this->status == 'processing' || $this->status == 'valid'); - } - - /** - * Requests the certificate for this LetsEncrypt Order instance, after finalization. When the order status is still 'processing', the order will be polled max - * four times with five seconds in between. If the status becomes 'valid' in the meantime, the certificate will be requested. Else, the function returns false. - * - * @return boolean Returns true if the certificate is stored successfully, false if the certificate could not be retrieved or the status remained 'processing'. - */ - public function getCertificate() - { - $polling = 0; - while($this->status == 'processing' && $polling < 4) - { - if($this->log >= LECLient::LOG_STATUS) LEFunctions::log('Certificate for \'' . $this->basename . '\' being processed. Retrying in 5 seconds...', 'function getCertificate'); - sleep(5); - $this->updateOrderData(); - $polling++; - } - if($this->status == 'valid' && !empty($this->certificateURL)) - { - $get = $this->connector->get($this->certificateURL); - if(strpos($get['header'], "200 OK") !== false) - { - if(preg_match_all('~(-----BEGIN\sCERTIFICATE-----[\s\S]+?-----END\sCERTIFICATE-----)~i', $get['body'], $matches)) - { - if (isset($this->certificateKeys['certificate'])) file_put_contents($this->certificateKeys['certificate'], $matches[0][0]); - - if(count($matches[0]) > 1 && isset($this->certificateKeys['fullchain_certificate'])) - { - $fullchain = $matches[0][0]."\n"; - for($i=1;$icertificateKeys['fullchain_certificate']), $fullchain); - } - if($this->log >= LECLient::LOG_STATUS) LEFunctions::log('Certificate for \'' . $this->basename . '\' saved', 'function getCertificate'); - return true; - } - else - { - if($this->log >= LECLient::LOG_STATUS) LEFunctions::log('Received invalid certificate for \'' . $this->basename . '\'. Cannot save certificate.', 'function getCertificate'); - } - } - else - { - if($this->log >= LECLient::LOG_STATUS) LEFunctions::log('Invalid response for certificate request for \'' . $this->basename . '\'. Cannot save certificate.', 'function getCertificate'); - } - } - else - { - if($this->log >= LECLient::LOG_STATUS) LEFunctions::log('Order for \'' . $this->basename . '\' not valid. Cannot retrieve certificate.', 'function getCertificate'); - } - return false; - } - - /** - * Revokes the certificate in the current LetsEncrypt Order instance, if existent. Unlike stated in the ACME draft, the certificate revoke request cannot be signed - * with the account private key, and will be signed with the certificate private key. - * - * @param int $reason The reason to revoke the LetsEncrypt Order instance certificate. Possible reasons can be found in section 5.3.1 of RFC5280. - * - * @return boolean Returns true if the certificate was successfully revoked, false if not. - */ - public function revokeCertificate($reason = 0) - { - if($this->status == 'valid') - { - if (isset($this->certificateKeys['certificate'])) $certFile = $this->certificateKeys['certificate']; - elseif (isset($this->certificateKeys['fullchain_certificate'])) $certFile = $this->certificateKeys['fullchain_certificate']; - else throw new \RuntimeException('certificateKeys[certificate] or certificateKeys[fullchain_certificate] required'); - - if(file_exists($certFile) && file_exists($this->certificateKeys['private_key'])) - { - $certificate = file_get_contents($this->certificateKeys['certificate']); - preg_match('~-----BEGIN\sCERTIFICATE-----(.*)-----END\sCERTIFICATE-----~s', $certificate, $matches); - $certificate = trim(LEFunctions::Base64UrlSafeEncode(base64_decode(trim($matches[1])))); - - $sign = $this->connector->signRequestJWK(array('certificate' => $certificate, 'reason' => $reason), $this->connector->revokeCert); - $post = $this->connector->post($this->connector->revokeCert, $sign); - if(strpos($post['header'], "200 OK") !== false) - { - if($this->log >= LECLient::LOG_STATUS) LEFunctions::log('Certificate for order \'' . $this->basename . '\' revoked.', 'function revokeCertificate'); - return true; - } - else - { - if($this->log >= LECLient::LOG_STATUS) LEFunctions::log('Certificate for order \'' . $this->basename . '\' cannot be revoked.', 'function revokeCertificate'); - } - } - else - { - if($this->log >= LECLient::LOG_STATUS) LEFunctions::log('Certificate for order \'' . $this->basename . '\' not found. Cannot revoke certificate.', 'function revokeCertificate'); - } - } - else - { - if($this->log >= LECLient::LOG_STATUS) LEFunctions::log('Order for \'' . $this->basename . '\' not valid. Cannot revoke certificate.', 'function revokeCertificate'); - } - return false; - } -} - -?> diff --git a/LICENSE b/LICENSE deleted file mode 100644 index e9f6e63..0000000 --- a/LICENSE +++ /dev/null @@ -1,21 +0,0 @@ -MIT License - -Copyright (c) 2018 Youri van Weegberg - -Permission is hereby granted, free of charge, to any person obtaining a copy -of this software and associated documentation files (the "Software"), to deal -in the Software without restriction, including without limitation the rights -to use, copy, modify, merge, publish, distribute, sublicense, and/or sell -copies of the Software, and to permit persons to whom the Software is -furnished to do so, subject to the following conditions: - -The above copyright notice and this permission notice shall be included in all -copies or substantial portions of the Software. - -THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR -IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, -FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE -AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER -LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, -OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE -SOFTWARE. diff --git a/LICENSE.md b/LICENSE.md new file mode 100644 index 0000000..0bafa21 --- /dev/null +++ b/LICENSE.md @@ -0,0 +1,22 @@ +# The MIT License (MIT) + +Copyright (c) 2018 Youri van Weegberg +Copyright (c) 2018 Paul Dixon + +> Permission is hereby granted, free of charge, to any person obtaining a copy +> of this software and associated documentation files (the "Software"), to deal +> in the Software without restriction, including without limitation the rights +> to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +> copies of the Software, and to permit persons to whom the Software is +> furnished to do so, subject to the following conditions: +> +> The above copyright notice and this permission notice shall be included in +> all copies or substantial portions of the Software. +> +> THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +> IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +> FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +> AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +> LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +> OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN +> THE SOFTWARE. diff --git a/PULL_REQUEST_TEMPLATE.md b/PULL_REQUEST_TEMPLATE.md new file mode 100644 index 0000000..86246b3 --- /dev/null +++ b/PULL_REQUEST_TEMPLATE.md @@ -0,0 +1,43 @@ + + +## Description + +Describe your changes in detail. + +## Motivation and context + +Why is this change required? What problem does it solve? + +If it fixes an open issue, please link to the issue here (if you write `fixes #num` +or `closes #num`, the issue will be automatically closed when the pull is accepted.) + +## How has this been tested? + +Please describe in detail how you tested your changes. + +Include details of your testing environment, and the tests you ran to +see how your change affects other areas of the code, etc. + +## Screenshots (if appropriate) + +## Types of changes + +What types of changes does your code introduce? Put an `x` in all the boxes that apply: +- [ ] Bug fix (non-breaking change which fixes an issue) +- [ ] New feature (non-breaking change which adds functionality) +- [ ] Breaking change (fix or feature that would cause existing functionality to change) + +## Checklist: + +Go over all the following points, and put an `x` in all the boxes that apply. + +Please, please, please, don't send your pull request until all of the boxes are ticked. Once your pull request is created, it will trigger a build on our [continuous integration](http://www.phptherightway.com/#continuous-integration) server to make sure your [tests and code style pass](https://help.github.com/articles/about-required-status-checks/). + +- [ ] I have read the **[CONTRIBUTING](CONTRIBUTING.md)** document. +- [ ] My pull request addresses exactly one patch/feature. +- [ ] I have created a branch for this patch/feature. +- [ ] Each individual commit in the pull request is meaningful. +- [ ] I have added tests to cover my changes. +- [ ] If my change requires a change to the documentation, I have updated it accordingly. + +If you're unsure about any of these, don't hesitate to ask. We're here to help! diff --git a/README.md b/README.md index 8d27910..b4bf228 100644 --- a/README.md +++ b/README.md @@ -1,49 +1,80 @@ -# LEClient -PHP LetsEncrypt client library for ACME v2. 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. The user has to have access to the web server or DNS management to be able to verify the domain is accessible/owned by the user. +# PHP Certificate Toolbox -## Current version +[![Latest Version on Packagist][ico-version]][link-packagist] +[![Software License][ico-license]](LICENSE.md) +[![Build Status][ico-travis]][link-travis] +[![Coverage Status][ico-scrutinizer]][link-scrutinizer] +[![Quality Score][ico-code-quality]][link-code-quality] -The current version is 1.1.0 +This is a LetsEncrypt client library for ACME v2, which allows for the automated +creation of free SSL/TLS certificates using PHP. This includes support for wildcard +certificates supported by LetsEncrypt since Feb 2018. -This client was developed with the use of the LetsEncrypt staging server for version 2. While version 2 is still being developed and implemented by LetsEncrypt at this moment, the project might be subject to change. +While this includes a command line tool, the real intent of this library is to +make it easy to integrate into existing PHP applications which need to issue +certificates. -## Getting Started +See the [LetsEncrypt documentation](https://letsencrypt.org/docs/) for more +information and documentation on LetsEncrypt and ACME. -These instructions will get you started with this client library. Is you have any questions or find any problems, feel free to open an issue and I'll try to have a look at it. +## Origins and roadmap -Also have a look at the [LetsEncrypt documentation](https://letsencrypt.org/docs/) for more information and documentation on LetsEncrypt and ACME. +This is based on the client developed by [Youri van Weegberg](https://github.com/yourivw/leclient), +but improved as follows -### Prerequisites +* composer-installable +* PSR-2 formatted +* PSR-3 logger compatible +* unit tests (some additional refactoring required to support this) +* support for alternative storage backends +* support for verifying DNS challenges using DNS-over-HTTPS -The minimum required PHP version is 7.1.0 due to the implementation of ECDSA. Version 1.0.0 does still work with PHP 5.2 since it is not yet compatible with ECDSA, and will be kept available, but will not be maintained. -This client also depends on cURL and OpenSSL. +## Prerequisites -### Installing +The minimum required PHP version is 7.1.0 due to the implementation of ECDSA. -Download and install the LEClient folder and examples wherever you want to install it. You can include the library by adding the following: -```php -require_once('LEClient/LEClient.php'); -``` +This client also depends on OpenSSL. -It is advisable to cut the script some slack regarding execution time by setting a higher maximum time. There are several ways to do so. One it to add the following to the top of the page: -```php -ini_set('max_execution_time', 120); // Maximum execution time in seconds. + +## Install + +Via Composer + +``` bash +$ composer require lordelph/php-certificate-toolbox ``` ## Usage -The basic functions and its necessary arguments are shown here. An extended description is included in each class. +The basic functions and its necessary arguments are shown here. An extended description +is included in each class. -
+It is advisable to cut the script some slack regarding execution time by setting a higher +maximum time. There are several ways to do so. One it to add the following to the top of +the page: + +```php +ini_set('max_execution_time', 120); // Maximum execution time in seconds. +``` Initiating the client: + ```php -$client = new LEClient($email); // Initiating a basic LEClient with an array of string e-mail address(es). -$client = new LEClient($email, true); // Initiating a LECLient and use the LetsEncrypt staging URL. -$client = new LEClient($email, true, LEClient::LOG_STATUS); // Initiating a LEClient and log status messages (LOG_DEBUG for full debugging). +use Elphin\LEClient; + +// Initiating a basic LEClient with an array of string e-mail address(es). +$client = new LEClient($email); + +// Initiating a LECLient and use the LetsEncrypt staging URL. +$client = new LEClient($email, true); + +// Initiating a LEClient and log status messages (LOG_DEBUG for full debugging). +$client = new LEClient($email, true, LEClient::LOG_STATUS); ``` -The client will automatically create a new account if there isn't one found. It will forward the e-mail address(es) supplied during initiation, as shown above. + +The client will automatically create a new account if there isn't one found. It will forward +the e-mail address(es) supplied during initiation, as shown above.
@@ -94,11 +125,24 @@ LEFunctions::createhtaccess($directory); // Created a simple .htaccess f ## Authorization challenges -LetsEncrypt (ACME) performs authorizations on the domains you want to include on your certificate, to verify you actually have access to the specific domain. Therefore, when creating an order, an authorization is added for each domain. If a domain has recently (in the last 30 days) been verified by your account, for example in another order, you don't have to verify again. At this time, a domain can be verified by a HTTP request to a file (http-01) or a DNS TXT record (dns-01). The client supplies the necessary data for the chosen verification by the call to getPendingAuthorizations(). Since creating a file or DNS record differs for every server, this is not implemented in the client. After the user has fulfilled the challenge requirements, a call has to be made to verifyPendingOrderAuthorization(). This client will first verify the challenge with checkHTTPChallenge() or checkDNSChallenge() by itself, before it is starting the verification by LetsEncrypt. Keep in mind, a wildcard domain can only be verified with a DNS challenge. An example for both challenges is shown below. +LetsEncrypt (ACME) performs authorizations on the domains you want to include on your +certificate, to verify you actually have access to the specific domain. Therefore, when +creating an order, an authorization is added for each domain. If a domain has recently +(in the last 30 days) been verified by your account, for example in another order, you +don't have to verify again. At this time, a domain can be verified by a HTTP request to +a file (http-01) or a DNS TXT record (dns-01). The client supplies the necessary data +for the chosen verification by the call to `getPendingAuthorizations()`. Since creating a +file or DNS record differs for every server, this is not implemented in the client. +After the user has fulfilled the challenge requirements, a call has to be made to +`verifyPendingOrderAuthorization()`. This client will first verify the challenge with +`checkHTTPChallenge()` or `checkDNSChallenge()` by itself, before it is starting the +verification by LetsEncrypt. Keep in mind, a wildcard domain can only be verified with +a DNS challenge. An example for both challenges is shown below. ### HTTP challenge For this example, we assume there is one domain left to verify. + ```php $pending = $order->getPendingAuthorizations(LEOrder::CHALLENGE_TYPE_HTTP); ``` @@ -119,11 +163,16 @@ For a successful verification, a request will be made to the following URL: ``` http://test.example.org/.well-known/acme-challenge/A8Q1DAVcd_k_oKAC0D_y4ln2IWrRX51jmXnR9UMMtOb ``` -The content of this file should be set to the content in the array above. The user should create this file before it can verify the authorization. +The content of this file should be set to the content in the array above. The user should +create this file before it can verify the authorization. ### DNS challenge -For this example, we assume there are two domains left to verify. One is a wildcard domain. The second domain in this example is added for demonstration purposes. Adding a subdomain to the certificate which is also already covered by the wildcard domain is does not offer much added value. +For this example, we assume there are two domains left to verify. One is a wildcard domain. +The second domain in this example is added for demonstration purposes. Adding a subdomain to +the certificate which is also already covered by the wildcard domain is does not offer much +added value. + ```php $pending = $order->getPendingAuthorizations(LEOrder::CHALLENGE_TYPE_DNS); ``` @@ -152,22 +201,97 @@ For a successful verification, DNS records should be created as follows: | \_acme-challenge.example.org | 60 | TXT | FV5HgbpjIYe1x9MkPI81Nffo2oA-Jo2S88gCL7-Ky5P | | \_acme-challenge.test.example.org | 60 | TXT | WM5YIsgaZQv1b9DbRZ81EwCf2fi-Af2JlgxTC7-Up5D | -The TTL value can be set higher if wanted or necessary, I prefer to keep it as low as possible for this purpose. To make sure the verification is successful, it would be advised to run a script using DNS challenges in two parts, with a certain amount of time in between to allow for the DNS record to update. The user himself should make sure to set this DNS record before the record can be verified. -The DNS record name also depends on your provider, therefore getPendingAuthorizations() does not give you a ready-to-use record name. Some providers only accept a name like `_acme-challenge`, without the top domain name, for `_acme-challenge.example.org`. Some providers accept (require?) a full name like shown above. +The TTL value can be set higher if wanted or necessary, I prefer to keep it as low as possible for +this purpose. To make sure the verification is successful, it would be advised to run a script +using DNS challenges in two parts, with a certain amount of time in between to allow for the DNS +record to update. The user himself should make sure to set this DNS record before the record can +be verified. -*A wildcard domain, like `*.example.org`, will be verified as `example.org`, as shown above. This means the DNS record name should be `_acme-challenge.example.org`* +The DNS record name also depends on your provider, therefore `getPendingAuthorizations()` does +not give you a ready-to-use record name. Some providers only accept a name like `_acme-challenge`, +without the top domain name, for `_acme-challenge.example.org`. Some providers accept (require?) +a full name like shown above. + +*A wildcard domain, like `*.example.org`, will be verified as `example.org`, as shown above. +This means the DNS record name should be `_acme-challenge.example.org`* ## Full example -For both HTTP and DNS authorizations, a full example is available in the project's main code directory. The HTTP authorization example is contained in one file. As described above, the DNS authorization example is split into two parts, to allow for the DNS record to update in the meantime. While the TTL of the record might be low, it can sometimes take some time for your provider to update your DNS records after an amendment. +For both HTTP and DNS authorizations, a full example is available in the project's main code +directory. The HTTP authorization example is contained in one file. As described above, the +DNS authorization example is split into two parts, to allow for the DNS record to update in +the meantime. While the TTL of the record might be low, it can sometimes take some time for +your provider to update your DNS records after an amendment. + +If you can't get these examples, or the client library to work, try and have a look at the +LetsEncrypt documentation mentioned above as well. + + +## Change log + +Please see [CHANGELOG](CHANGELOG.md) for more information on what has changed recently. + +## Testing + +Unit tests are executed as follows: + +``` bash +$ composer test +``` + +The test suite includes some integration tests with external dependencies, e.g. verifying +that each supported DNS-over-HTTP service works as expected. The full test suite can be +run with + +``` bash +$ composer test-all +``` + +## Contributing + +Please see [CONTRIBUTING](CONTRIBUTING.md) and [CODE_OF_CONDUCT](CODE_OF_CONDUCT.md) for details. -If you can't get these examples, or the client library to work, try and have a look at the LetsEncrypt documentation mentioned above as well. ## Security -Security is an important subject regarding SSL/TLS certificates, of course. Since this client is a PHP script, it is likely this code is running on a web server. It is obvious that your private key, stored on your web server, should never be accessible from the web. -When the client created the keys directory for the first time, it will store a .htaccess file in this directory, denying all visitors. Always make sure yourself your keys aren't accessible from the web! I am in no way responsible if your private keys go public. If this does happen, the easiest solution is to change your account keys (described above) or deactivate your account and create a new one. Next, create a new certificate. +Security is an important subject regarding SSL/TLS certificates, of course. Since this client is +a PHP script, it is likely this code is running on a web server. It is obvious that your private +key, stored on your web server, should never be accessible from the web. + +When the client created the keys directory for the first time, it will store a .htaccess file in +this directory, denying all visitors. Always make sure yourself your keys aren't accessible from +the web! I am in no way responsible if your private keys go public. If this does happen, the +easiest solution is to change your account keys (described above) or deactivate your account and +create a new one. Next, create a new certificate. + +If you discover any security related issues, please email paul@elphin.com instead of using the +issue tracker. + +## Credits + +- [Paul Dixon][link-author] Refactoring inc unit tests and storage interface +- [Youri van Weegberg][link-author2] Original PHP ACME2 client on which this is based +- [wutno][link-author3] DNS-over-HTTPS support + +- [All Contributors][link-contributors] ## License -This project is licensed under the MIT License - see the [LICENSE.md](LICENSE.md) file for details. +The MIT License (MIT). Please see [License File](LICENSE.md) for more information. + +[ico-version]: https://img.shields.io/packagist/v/zwartpet/php-certificate-toolbox.svg?style=flat-square +[ico-license]: https://img.shields.io/badge/license-MIT-brightgreen.svg?style=flat-square +[ico-travis]: https://img.shields.io/travis/zwartpet/php-certificate-toolbox/master.svg?style=flat-square +[ico-scrutinizer]: https://img.shields.io/scrutinizer/coverage/g/zwartpet/php-certificate-toolbox.svg?style=flat-square +[ico-code-quality]: https://img.shields.io/scrutinizer/g/zwartpet/php-certificate-toolbox.svg?style=flat-square + +[link-packagist]: https://packagist.org/packages/zwartpet/php-certificate-toolbox +[link-travis]: https://travis-ci.org/zwartpet/php-certificate-toolbox +[link-scrutinizer]: https://scrutinizer-ci.com/g/zwartpet/php-certificate-toolbox/code-structure +[link-code-quality]: https://scrutinizer-ci.com/g/zwartpet/php-certificate-toolbox +[link-downloads]: https://packagist.org/packages/zwartpet/php-certificate-toolbox +[link-author]: https://github.com/zwartpet +[link-author2]: https://github.com/lordelph +[link-author3]:https://github.com/yourivw +[link-author3]:https://github.com/GXTX +[link-contributors]: ../../contributors diff --git a/composer.json b/composer.json index 1bcc648..db150b4 100644 --- a/composer.json +++ b/composer.json @@ -1,14 +1,68 @@ { - "name": "yourivw/LEClient", + "name": "zwartpet/php-certificate-toolbox", "type": "library", - "description": "PHP LetsEncrypt client library for ACME v2", - "repositories": [ + "description": "ACME v2 client for Let's Encrypt", + "keywords": [ + "Lets Encrypt", + "ACME", + "LE", + "Certificate" + ], + "homepage": "https://github.com/zwartpet/php-certificate-toolbox", + "license": "MIT", + "authors": [ + { + "name": "John Zwarthoed", + "homepage": "https://github.com/zwartpet", + "role": "Developer" + }, + { + "name": "Paul Dixon", + "email": "paul@elphin.com", + "homepage": "http://blog.dixo.net", + "role": "Developer" + }, { - "url": "https://github.com/yourivw/LEClient.git", - "type": "git" + "name": "Youri van Weegberg", + "homepage": "https://github.com/yourivw/LEClient", + "role": "Developer" } ], "require": { - "yourivw/LEClient": "^1.1.0" + "php": "~7.0", + "ext-openssl": "*", + "guzzlehttp/guzzle": "~6.0", + "psr/log": "^1.0" + }, + "require-dev": { + "phpunit/phpunit" : ">=5.4.3", + "squizlabs/php_codesniffer": "^2.3" + }, + "suggest": { + "psr/log-implementation": "A PSR-3 compatible logger is recommended for troubleshooting" + }, + "autoload": { + "psr-4": { + "Zwartpet\\PHPCertificateToolbox\\": "src" + } + }, + "autoload-dev": { + "psr-4": { + "Zwartpet\\PHPCertificateToolbox\\": "tests" + } + }, + "scripts": { + "test": "phpunit --exclude-group integration", + "test-all": "phpunit", + "check-style": "phpcs -p --standard=PSR2 --runtime-set ignore_errors_on_exit 1 --runtime-set ignore_warnings_on_exit 1 src tests", + "fix-style": "phpcbf -p --standard=PSR2 --runtime-set ignore_errors_on_exit 1 --runtime-set ignore_warnings_on_exit 1 src tests" + }, + "extra": { + "branch-alias": { + "dev-master": "1.0-dev" + } + }, + "config": { + "sort-packages": true } } diff --git a/exampleDNSInit.php b/exampleDNSInit.php deleted file mode 100644 index 464a46b..0000000 --- a/exampleDNSInit.php +++ /dev/null @@ -1,33 +0,0 @@ -getOrCreateOrder($basename, $domains); -// Check whether there are any authorizations pending. If that is the case, try to verify the pending authorizations. -if(!$order->allAuthorizationsValid()) -{ - // Get the DNS challenges from the pending authorizations. - $pending = $order->getPendingAuthorizations(LEOrder::CHALLENGE_TYPE_DNS); - // Walk the list of pending authorization DNS challenges. - if(!empty($pending)) - { - foreach($pending as $challenge) - { - // For the purpose of this example, a fictitious functions creates or updates the ACME challenge DNS record for this domain. - setDNSRecord($challenge['identifier'], $challenge['DNSDigest']) - } - } -} -?> \ No newline at end of file diff --git a/exampleDNSFinish.php b/examples/exampleDNSFinish.php similarity index 73% rename from exampleDNSFinish.php rename to examples/exampleDNSFinish.php index e3061d0..7d4227f 100644 --- a/exampleDNSFinish.php +++ b/examples/exampleDNSFinish.php @@ -1,8 +1,10 @@ getOrCreateOrder($basename, $domains); // Check whether there are any authorizations pending. If that is the case, try to verify the pending authorizations. if(!$order->allAuthorizationsValid()) @@ -37,5 +47,12 @@ if(!$order->isFinalized()) $order->finalizeOrder(); // Check whether the order has been finalized before we can get the certificate. If finalized, get the certificate. if($order->isFinalized()) $order->getCertificate(); + + //finally, here's how we revoke + //echo "REVOKING...\n"; + //$order->revokeCertificate(); } -?> \ No newline at end of file + + +echo "\nDiagnostic logs\n"; +$logger->dumpConsole(); diff --git a/examples/exampleDNSInit.php b/examples/exampleDNSInit.php new file mode 100644 index 0000000..9b5aa0a --- /dev/null +++ b/examples/exampleDNSInit.php @@ -0,0 +1,59 @@ +getOrCreateOrder($basename, $domains); +// Check whether there are any authorizations pending. If that is the case, try to verify the pending authorizations. + if (!$order->allAuthorizationsValid()) { + // Get the DNS challenges from the pending authorizations. + $pending = $order->getPendingAuthorizations(LEOrder::CHALLENGE_TYPE_DNS); + // Walk the list of pending authorization DNS challenges. + if (!empty($pending)) { + foreach ($pending as $challenge) { + // For the purpose of this example, a fictitious functions creates or updates the ACME challenge DNS + // record for this domain. + //setDNSRecord($challenge['identifier'], $challenge['DNSDigest']); + printf( + "DNS Challengage identifier = %s digest = %s\n", + $challenge['identifier'], + $challenge['DNSDigest'] + ); + } + } + } +} +catch (\Exception $e) { + echo $e->getMessage()."\n"; + echo $e->getTraceAsString()."\n"; + + echo "\nDiagnostic logs\n"; + $logger->dumpConsole(); + exit; +} + +echo "\nDiagnostic logs\n"; +$logger->dumpConsole(); + diff --git a/exampleHTTP.php b/examples/exampleHTTP.php similarity index 80% rename from exampleHTTP.php rename to examples/exampleHTTP.php index 4140f03..e1617a3 100644 --- a/exampleHTTP.php +++ b/examples/exampleHTTP.php @@ -1,8 +1,10 @@ getOrCreateOrder($basename, $domains); // Check whether there are any authorizations pending. If that is the case, try to verify the pending authorizations. if(!$order->allAuthorizationsValid()) @@ -25,7 +31,8 @@ { foreach($pending as $challenge) { - // Define the folder in which to store the challenge. For the purpose of this example, a fictitious path is set. + // Define the folder in which to store the challenge. For the purpose of this example, a fictitious path is + // set. $folder = '/path/to/' . $challenge['identifier'] . '/.well-known/acme-challenge/'; // Check if that directory yet exists. If not, create it. if(!file_exists($folder)) mkdir($folder, 0777, true); @@ -44,4 +51,6 @@ // Check whether the order has been finalized before we can get the certificate. If finalized, get the certificate. if($order->isFinalized()) $order->getCertificate(); } -?> \ No newline at end of file + +echo "\nDiagnostic logs\n"; +$logger->dumpConsole(); \ No newline at end of file diff --git a/phpunit.xml.dist b/phpunit.xml.dist new file mode 100644 index 0000000..0b2de5f --- /dev/null +++ b/phpunit.xml.dist @@ -0,0 +1,29 @@ + + + + + tests + + + + + src/ + + + + + + + + + + diff --git a/src/CertificateStorageInterface.php b/src/CertificateStorageInterface.php new file mode 100644 index 0000000..9f323fd --- /dev/null +++ b/src/CertificateStorageInterface.php @@ -0,0 +1,116 @@ +baseURI = self::DNS_GOOGLE; + } else { + $this->baseURI = $baseURI; + } + + $this->client = new Client([ + 'base_uri' => $this->baseURI + ]); + } + + public function checkChallenge($domain, $requiredDigest) : bool + { + $hostname = '_acme-challenge.' . str_replace('*.', '', $domain); + + $records = $this->get($hostname, 'TXT'); + if ($records->Status == 0) { + foreach ($records->Answer as $record) { + if ((rtrim($record->name, ".") == $hostname) && + ($record->type == 16) && + (trim($record->data, '"') == $requiredDigest)) { + return true; + } + } + } + + return false; + } + + /** + * @param string $name + * @param string $type per experimental spec this can be string OR int, we force string + * @return \stdClass + */ + public function get(string $name, string $type) : \stdClass + { + $query = [ + 'query' => [ + 'name' => $name, + 'type' => $type, + 'edns_client_subnet' => '0.0.0.0/0', //disable geotagged dns results + 'ct' => 'application/dns-json', //cloudflare requires this + ], + 'headers' => [ + 'Accept' => 'application/dns-json' + ] + ]; + + try { + $response = $this->client->get(null, $query); + } catch (BadResponseException $e) { + throw new RuntimeException("GET {$this->baseURI} failed", 0, $e); + } + + $decode = json_decode($response->getBody()); + + if (json_last_error() !== JSON_ERROR_NONE) { + throw new RuntimeException( + 'Attempted to decode expected JSON response, however server returned something unexpected.' + ); + } + + return $decode; + } +} diff --git a/src/DNSValidator/DNSValidatorInterface.php b/src/DNSValidator/DNSValidatorInterface.php new file mode 100644 index 0000000..2c1db24 --- /dev/null +++ b/src/DNSValidator/DNSValidatorInterface.php @@ -0,0 +1,23 @@ +logs[] = [$level, $message, $context]; + } + + public function dumpConsole($useColours = true) + { + $colours = [ + 'alert' => "\e[97m\e[41m", + 'emergency' => "\e[97m\e[41m", + 'critical' => "\e[97m\e[41m", + 'error' => "\e[91m", + 'warning' => "\e[93m", + 'notice' => "\e[96m", + 'info' => "\e[92m", + 'debug' => "\e[2m", + ]; + + $reset = $useColours ? "\e[0m" : ''; + + foreach ($this->logs as $log) { + $col = $useColours ? $colours[$log[0]] : ''; + echo $col . $log[0] . ': ' . $this->interpolateMessage($log[1], $log[2]) . $reset . "\n"; + } + } + + public function dumpHTML($echo = true) + { + $html = '
'; + $html .= ''; + $html .= "\n"; + + foreach ($this->logs as $log) { + $html .= '\n"; + } + $html .= "
LevelMessage
' . $log[0] . '' . + htmlentities($this->interpolateMessage($log[1], $log[2])) . + "
\n"; + + if ($echo) { + echo $html; //@codeCoverageIgnore + } + return $html; + } + + /** + * Interpolates context values into the message placeholders. + */ + private function interpolateMessage($message, array $context = []) + { + // build a replacement array with braces around the context keys + $replace = []; + foreach ($context as $key => $val) { + // check that the value can be casted to string + if (!is_array($val) && (!is_object($val) || method_exists($val, '__toString'))) { + $replace['{' . $key . '}'] = $val; + } + } + + // interpolate replacement values into the message and return + return strtr($message, $replace); + } + + + public function cleanLogs() + { + $logs = $this->logs; + $this->logs = []; + + return $logs; + } + + public function countLogs($level) + { + $count = 0; + foreach ($this->logs as $log) { + if ($log[0] == $level) { + $count++; + } + } + return $count; + } +} diff --git a/src/Exception/LEClientException.php b/src/Exception/LEClientException.php new file mode 100644 index 0000000..82365c2 --- /dev/null +++ b/src/Exception/LEClientException.php @@ -0,0 +1,12 @@ +dir = $dir ?? getcwd().DIRECTORY_SEPARATOR.'certificates'; + + if (!is_dir($this->dir)) { + /** @scrutinizer ignore-unhandled */ @mkdir($this->dir); + } + if (!is_writable($this->dir)) { + throw new RuntimeException("{$this->dir} is not writable"); + } + } + + + /** + * @inheritdoc + */ + public function getAccountPublicKey() + { + return $this->getMetadata('account.public'); + } + + /** + * @inheritdoc + */ + public function setAccountPublicKey($key) + { + $this->setMetadata('account.public', $key); + } + + /** + * @inheritdoc + */ + public function getAccountPrivateKey() + { + return $this->getMetadata('account.key'); + } + + /** + * @inheritdoc + */ + public function setAccountPrivateKey($key) + { + $this->setMetadata('account.key', $key); + } + + private function getDomainKey($domain, $suffix) + { + return str_replace('*', 'wildcard', $domain).'.'.$suffix; + } + /** + * @inheritdoc + */ + public function getCertificate($domain) + { + return $this->getMetadata($this->getDomainKey($domain, 'crt')); + } + + /** + * @inheritdoc + */ + public function setCertificate($domain, $certificate) + { + $this->setMetadata($this->getDomainKey($domain, 'crt'), $certificate); + } + + /** + * @inheritdoc + */ + public function getFullChainCertificate($domain) + { + return $this->getMetadata($this->getDomainKey($domain, 'fullchain.crt')); + } + + /** + * @inheritdoc + */ + public function setFullChainCertificate($domain, $certificate) + { + $this->setMetadata($this->getDomainKey($domain, 'fullchain.crt'), $certificate); + } + + /** + * @inheritdoc + */ + public function getPrivateKey($domain) + { + return $this->getMetadata($this->getDomainKey($domain, 'key')); + } + + /** + * @inheritdoc + */ + public function setPrivateKey($domain, $key) + { + $this->setMetadata($this->getDomainKey($domain, 'key'), $key); + } + + /** + * @inheritdoc + */ + public function getPublicKey($domain) + { + return $this->getMetadata($this->getDomainKey($domain, 'public')); + } + + /** + * @inheritdoc + */ + public function setPublicKey($domain, $key) + { + $this->setMetadata($this->getDomainKey($domain, 'public'), $key); + } + + private function getMetadataFilename($key) + { + $key=str_replace('*', 'wildcard', $key); + $file=$this->dir.DIRECTORY_SEPARATOR.$key; + return $file; + } + /** + * @inheritdoc + */ + public function getMetadata($key) + { + $file=$this->getMetadataFilename($key); + if (!file_exists($file)) { + return null; + } + return file_get_contents($file); + } + + /** + * @inheritdoc + */ + public function setMetadata($key, $value) + { + $file=$this->getMetadataFilename($key); + if (is_null($value)) { + //nothing to store, ensure file is removed + if (file_exists($file)) { + unlink($file); + } + } else { + file_put_contents($file, $value); + } + } + + /** + * @inheritdoc + */ + public function hasMetadata($key) + { + $file=$this->getMetadataFilename($key); + return file_exists($file); + } +} diff --git a/src/LEAccount.php b/src/LEAccount.php new file mode 100644 index 0000000..e3d7b33 --- /dev/null +++ b/src/LEAccount.php @@ -0,0 +1,246 @@ + + * @copyright 2018 Youri van Weegberg + * @license https://opensource.org/licenses/mit-license.php MIT License + */ +class LEAccount +{ + private $connector; + + public $id; + public $key; + public $contact; + public $agreement; + public $initialIp; + public $createdAt; + public $status; + + /** @var LoggerInterface */ + private $log; + + /** @var CertificateStorageInterface */ + private $storage; + + /** + * Initiates the LetsEncrypt Account class. + * + * @param LEConnector $connector The LetsEncrypt Connector instance to use for HTTP requests. + * @param LoggerInterface $log PSR-3 compatible logger + * @param array $email The array of strings containing e-mail addresses. Only used when creating a + * new account. + * @param CertificateStorageInterface $storage storage for account keys + */ + public function __construct($connector, LoggerInterface $log, $email, CertificateStorageInterface $storage) + { + $this->connector = $connector; + $this->storage = $storage; + $this->log = $log; + + if (empty($storage->getAccountPublicKey()) || empty($storage->getAccountPrivateKey())) { + $this->log->notice("No account found for ".implode(',', $email).", attempting to create account"); + + $accountKey = LEFunctions::RSAgenerateKeys(); + $storage->setAccountPublicKey($accountKey['public']); + $storage->setAccountPrivateKey($accountKey['private']); + + $this->connector->accountURL = $this->createLEAccount($email); + } else { + $this->connector->accountURL = $this->getLEAccount(); + } + if ($this->connector->accountURL === false) { + throw new RuntimeException('Account not found or deactivated.'); + } + $this->getLEAccountData(); + } + + /** + * Creates a new LetsEncrypt account. + * + * @param array $email The array of strings containing e-mail addresses. + * + * @return string|bool Returns the new account URL when the account was successfully created, false if not. + */ + private function createLEAccount($email) + { + $contact = array_map(function ($addr) { + return empty($addr) ? '' : (strpos($addr, 'mailto') === false ? 'mailto:' . $addr : $addr); + }, $email); + + $sign = $this->connector->signRequestJWK( + ['contact' => $contact, 'termsOfServiceAgreed' => true], + $this->connector->newAccount + ); + $post = $this->connector->post($this->connector->newAccount, $sign); + if (strpos($post['header'], "201 Created") !== false) { + if (preg_match('~Location: (\S+)~i', $post['header'], $matches)) { + return trim($matches[1]); + } + } + //@codeCoverageIgnoreStart + return false; + //@codeCoverageIgnoreEnd + } + + /** + * Gets the LetsEncrypt account URL associated with the stored account keys. + * + * @return string|bool Returns the account URL if it is found, or false when none is found. + */ + private function getLEAccount() + { + $sign = $this->connector->signRequestJWK(['onlyReturnExisting' => true], $this->connector->newAccount); + $post = $this->connector->post($this->connector->newAccount, $sign); + + if (strpos($post['header'], "200 OK") !== false) { + if (preg_match('~Location: (\S+)~i', $post['header'], $matches)) { + return trim($matches[1]); + } + } + return false; + } + + /** + * Gets the LetsEncrypt account data from the account URL. + */ + private function getLEAccountData() + { + $sign = $this->connector->signRequestKid( + ['' => ''], + $this->connector->accountURL, + $this->connector->accountURL + ); + $post = $this->connector->post($this->connector->accountURL, $sign); + if (strpos($post['header'], "200 OK") !== false) { + $this->id = isset($post['body']['id']) ? $post['body']['id'] : ''; + $this->key = $post['body']['key']; + $this->contact = $post['body']['contact']; + $this->agreement = isset($post['body']['agreement']) ? $post['body']['agreement'] : null; + $this->initialIp = $post['body']['initialIp']; + $this->createdAt = $post['body']['createdAt']; + $this->status = $post['body']['status']; + } else { + //@codeCoverageIgnoreStart + throw new RuntimeException('Account data cannot be found.'); + //@codeCoverageIgnoreEnd + } + } + + /** + * Updates account data. Now just supporting new contact information. + * + * @param array $email The array of strings containing e-mail adresses. + * + * @return boolean Returns true if the update is successful, false if not. + */ + public function updateAccount($email) + { + $contact = array_map(function ($addr) { + return empty($addr) ? '' : (strpos($addr, 'mailto') === false ? 'mailto:' . $addr : $addr); + }, $email); + + $sign = $this->connector->signRequestKid( + ['contact' => $contact], + $this->connector->accountURL, + $this->connector->accountURL + ); + $post = $this->connector->post($this->connector->accountURL, $sign); + if ($post['status'] !== 200) { + //@codeCoverageIgnoreStart + throw new RuntimeException('Unable to update account'); + //@codeCoverageIgnoreEnd + } + + $this->id = isset($post['body']['id']) ? $post['body']['id'] : ''; + $this->key = $post['body']['key']; + $this->contact = $post['body']['contact']; + $this->agreement = isset($post['body']['agreement']) ? $post['body']['agreement'] : ''; + $this->initialIp = $post['body']['initialIp']; + $this->createdAt = $post['body']['createdAt']; + $this->status = $post['body']['status']; + + $this->log->notice('Account data updated'); + return true; + } + + /** + * Creates new RSA account keys and updates the keys with LetsEncrypt. + * + * @return boolean Returns true if the update is successful, false if not. + */ + public function changeAccountKeys() + { + $new=LEFunctions::RSAgenerateKeys(); + + $privateKey = openssl_pkey_get_private($new['private']); + if ($privateKey === false) { + //@codeCoverageIgnoreStart + throw new RuntimeException('Failed to open newly generated private key'); + //@codeCoverageIgnoreEnd + } + + + $details = openssl_pkey_get_details($privateKey); + $innerPayload = ['account' => $this->connector->accountURL, 'newKey' => [ + "kty" => "RSA", + "n" => LEFunctions::base64UrlSafeEncode($details["rsa"]["n"]), + "e" => LEFunctions::base64UrlSafeEncode($details["rsa"]["e"]) + ]]; + $outerPayload = $this->connector->signRequestJWK( + $innerPayload, + $this->connector->keyChange, + $new['private'] + ); + $sign = $this->connector->signRequestKid( + $outerPayload, + $this->connector->accountURL, + $this->connector->keyChange + ); + $post = $this->connector->post($this->connector->keyChange, $sign); + if ($post['status'] !== 200) { + //@codeCoverageIgnoreStart + throw new RuntimeException('Unable to post new account keys'); + //@codeCoverageIgnoreEnd + } + + $this->getLEAccountData(); + + $this->storage->setAccountPublicKey($new['public']); + $this->storage->setAccountPrivateKey($new['private']); + + $this->log->notice('Account keys changed'); + return true; + } + + /** + * Deactivates the LetsEncrypt account. + * + * @return boolean Returns true if the deactivation is successful, false if not. + */ + public function deactivateAccount() + { + $sign = $this->connector->signRequestKid( + ['status' => 'deactivated'], + $this->connector->accountURL, + $this->connector->accountURL + ); + $post = $this->connector->post($this->connector->accountURL, $sign); + if ($post['status'] !== 200) { + //@codeCoverageIgnoreStart + $this->log->error('Account deactivation failed'); + return false; + //@codeCoverageIgnoreEnd + } + + $this->connector->accountDeactivated = true; + $this->log->info('Account deactivated'); + return true; + } +} diff --git a/src/LEAuthorization.php b/src/LEAuthorization.php new file mode 100644 index 0000000..e3490ed --- /dev/null +++ b/src/LEAuthorization.php @@ -0,0 +1,94 @@ + + * @copyright 2018 Youri van Weegberg + * @license https://opensource.org/licenses/mit-license.php MIT License + */ +class LEAuthorization +{ + private $connector; + + public $authorizationURL; + public $identifier; + public $status; + public $expires; + public $challenges; + + /** @var LoggerInterface */ + private $log; + + /** + * Initiates the LetsEncrypt Authorization class. Child of a LetsEncrypt Order instance. + * + * @param LEConnector $connector The LetsEncrypt Connector instance to use for HTTP requests. + * @param LoggerInterface $log PSR-3 logger + * @param string $authorizationURL The URL of the authorization, given by a LetsEncrypt order request. + */ + public function __construct($connector, LoggerInterface $log, $authorizationURL) + { + $this->connector = $connector; + $this->log = $log; + $this->authorizationURL = $authorizationURL; + + $get = $this->connector->get($this->authorizationURL); + if ($get['status'] === 200) { + $this->identifier = $get['body']['identifier']; + $this->status = $get['body']['status']; + $this->expires = $get['body']['expires']; + $this->challenges = $get['body']['challenges']; + } else { + //@codeCoverageIgnoreStart + $this->log->error("LEAuthorization::__construct cannot find authorization $authorizationURL"); + //@codeCoverageIgnoreEnd + } + } + + /** + * Updates the data associated with the current LetsEncrypt Authorization instance. + */ + + public function updateData() + { + $get = $this->connector->get($this->authorizationURL); + if ($get['status'] === 200) { + $this->identifier = $get['body']['identifier']; + $this->status = $get['body']['status']; + $this->expires = $get['body']['expires']; + $this->challenges = $get['body']['challenges']; + } else { + //@codeCoverageIgnoreStart + $this->log->error("LEAuthorization::updateData cannot find authorization " . $this->authorizationURL); + //@codeCoverageIgnoreEnd + } + } + + /** + * Gets the challenge of the given $type for this LetsEncrypt Authorization instance. + * Throws a Runtime Exception if the given $type is not found in this LetsEncrypt Authorization instance. + * + * @param string $type The type of verification. + * Supporting LEOrder::CHALLENGE_TYPE_HTTP and LEOrder::CHALLENGE_TYPE_DNS. + * + * @return array Returns an array with the challenge of the requested $type. + */ + public function getChallenge($type) + { + foreach ($this->challenges as $challenge) { + if ($challenge['type'] == $type) { + return $challenge; + } + } + //@codeCoverageIgnoreStart + throw new RuntimeException( + 'No challenge found for type \'' . $type . '\' and identifier \'' . $this->identifier['value'] . '\'.' + ); + //@codeCoverageIgnoreEnd + } +} diff --git a/src/LEClient.php b/src/LEClient.php new file mode 100644 index 0000000..6c92eea --- /dev/null +++ b/src/LEClient.php @@ -0,0 +1,176 @@ + + * @copyright 2018 Youri van Weegberg + * @license https://opensource.org/licenses/mit-license.php MIT License + */ +class LEClient +{ + const LE_PRODUCTION = 'https://acme-v02.api.letsencrypt.org'; + const LE_STAGING = 'https://acme-staging-v02.api.letsencrypt.org'; + + /** @var LEConnector */ + private $connector; + + /** @var LEAccount */ + private $account; + + private $baseURL; + + /** @var LoggerInterface */ + private $log; + + /** @var ClientInterface */ + private $httpClient; + + /** @var DNSValidatorInterface */ + private $dns; + + /** @var Sleep */ + private $sleep; + + /** @var CertificateStorageInterface */ + private $storage; + + + private $email; + + /** + * Initiates the LetsEncrypt main client. + * + * @param array $email The array of strings containing e-mail addresses. Only used in this function when + * creating a new account. + * @param string|bool $acmeURL ACME URL, can be string or one of predefined values: LE_STAGING or LE_PRODUCTION. + * Defaults to LE_STAGING. Can also pass true/false for staging/production + * @param LoggerInterface $logger PSR-3 compatible logger + * @param ClientInterface|null $httpClient you can pass a custom client used for HTTP requests, if null is passed + * one will be created + * @param CertificateStorageInterface|null $storage service for certificates. If not supplied, a default + * storage object will retain certificates in the local filesystem in a directory + * called certificates in the current working directory + * @param DNSValidatorInterface|null $dnsValidator service for checking DNS challenges. By default, this will use + * Google's DNS over HTTPs service, which should insulate you from cached entries, + * but this can be swapped for 'NativeDNS' or other alternative implementation + */ + public function __construct( + $email, + $acmeURL = LEClient::LE_STAGING, + LoggerInterface $logger = null, + ClientInterface $httpClient = null, + CertificateStorageInterface $storage = null, + DNSValidatorInterface $dnsValidator = null + ) { + $this->log = $logger ?? new NullLogger(); + + $this->initBaseUrl($acmeURL); + + $this->httpClient = $httpClient ?? new Client(); + + $this->storage = $storage ?? new FilesystemCertificateStorage(); + $this->dns = $dnsValidator ?? new DNSOverHTTPS(); + $this->sleep = new Sleep; + $this->email = $email; + } + + private function initBaseUrl($acmeURL) + { + if (is_bool($acmeURL)) { + $this->baseURL = $acmeURL ? LEClient::LE_STAGING : LEClient::LE_PRODUCTION; + } elseif (is_string($acmeURL)) { + $this->baseURL = $acmeURL; + } else { + throw new LogicException('acmeURL must be set to string or bool (legacy)'); + } + } + + public function getBaseUrl() + { + return $this->baseURL; + } + + /** + * Inject alternative DNS resolver for testing + * @param DNSValidatorInterface $dns + */ + public function setDNS(DNSValidatorInterface $dns) + { + $this->dns = $dns; + } + + /** + * Inject alternative sleep service for testing + * @param Sleep $sleep + */ + public function setSleep(Sleep $sleep) + { + $this->sleep = $sleep; + } + + private function getConnector() + { + if (!isset($this->connector)) { + $this->connector = new LEConnector($this->log, $this->httpClient, $this->baseURL, $this->storage); + + //we need to initialize an account before using the connector + $this->getAccount(); + } + + return $this->connector; + } + + /** + * Returns the LetsEncrypt account used in the current client. + * + * @return LEAccount The LetsEncrypt Account instance used by the client. + */ + public function getAccount() + { + if (!isset($this->account)) { + $this->account = new LEAccount($this->getConnector(), $this->log, $this->email, $this->storage); + } + return $this->account; + } + + /** + * Returns a LetsEncrypt order. If an order exists, this one is returned. If not, a new order is created and + * returned. + * + * @param string $basename The base name for the order. Preferable the top domain (example.org). Will be the + * directory in which the keys are stored. Used for the CommonName in the certificate as + * well. + * @param array $domains The array of strings containing the domain names on the certificate. + * @param string $keyType Type of the key we want to use for certificate. Can be provided in ALGO-SIZE format + * (ex. rsa-4096 or ec-256) or simple "rsa" and "ec" (using default sizes) + * @param string $notBefore A date string formatted like 0000-00-00T00:00:00Z (yyyy-mm-dd hh:mm:ss) at which the + * certificate becomes valid. Defaults to the moment the order is finalized. (optional) + * @param string $notAfter A date string formatted like 0000-00-00T00:00:00Z (yyyy-mm-dd hh:mm:ss) until which the + * certificate is valid. Defaults to 90 days past the moment the order is finalized. + * (optional) + * + * @return LEOrder The LetsEncrypt Order instance which is either retrieved or created. + */ + public function getOrCreateOrder($basename, $domains, $keyType = 'rsa-4096', $notBefore = '', $notAfter = '') + { + $this->log->info("LEClient::getOrCreateOrder($basename,...)"); + + $order = new LEOrder($this->getConnector(), $this->storage, $this->log, $this->dns, $this->sleep); + $order->loadOrder($basename, $domains, $keyType, $notBefore, $notAfter); + + return $order; + } +} diff --git a/src/LEConnector.php b/src/LEConnector.php new file mode 100755 index 0000000..fcd3864 --- /dev/null +++ b/src/LEConnector.php @@ -0,0 +1,355 @@ + + * @copyright 2018 Youri van Weegberg + * @license https://opensource.org/licenses/mit-license.php MIT License + */ +class LEConnector +{ + public $baseURL; + + private $nonce; + + public $keyChange; + public $newAccount; + public $newNonce; + public $newOrder; + public $revokeCert; + + public $accountURL; + public $accountDeactivated = false; + + /** @var LoggerInterface */ + private $log; + + /** @var ClientInterface */ + private $httpClient; + + /** @var CertificateStorageInterface */ + private $storage; + + /** + * Initiates the LetsEncrypt Connector class. + * + * @param LoggerInterface $log + * @param ClientInterface $httpClient + * @param string $baseURL The LetsEncrypt server URL to make requests to. + * @param CertificateStorageInterface $storage + */ + public function __construct( + LoggerInterface $log, + ClientInterface $httpClient, + $baseURL, + CertificateStorageInterface $storage + ) { + + $this->baseURL = $baseURL; + $this->storage = $storage; + $this->log = $log; + $this->httpClient = $httpClient; + + $this->getLEDirectory(); + $this->getNewNonce(); + } + + /** + * Requests the LetsEncrypt Directory and stores the necessary URLs in this LetsEncrypt Connector instance. + */ + private function getLEDirectory() + { + $req = $this->get('/directory'); + $this->keyChange = $req['body']['keyChange']; + $this->newAccount = $req['body']['newAccount']; + $this->newNonce = $req['body']['newNonce']; + $this->newOrder = $req['body']['newOrder']; + $this->revokeCert = $req['body']['revokeCert']; + } + + /** + * Requests a new nonce from the LetsEncrypt server and stores it in this LetsEncrypt Connector instance. + */ + private function getNewNonce() + { + $result = $this->head($this->newNonce); + + if ($result['status'] !== 200) { + //@codeCoverageIgnoreStart + throw new RuntimeException("No new nonce - fetched {$this->newNonce} got " . $result['header']); + //@codeCoverageIgnoreEnd + } + } + + /** + * Makes a request to the HTTP challenge URL and checks whether the authorization is valid for the given $domain. + * + * @param string $domain The domain to check the authorization for. + * @param string $token The token (filename) to request. + * @param string $keyAuthorization the keyAuthorization (file content) to compare. + * + * @return boolean Returns true if the challenge is valid, false if not. + */ + public function checkHTTPChallenge($domain, $token, $keyAuthorization) + { + $requestURL = $domain . '/.well-known/acme-challenge/' . $token; + + $request = new Request('GET', $requestURL); + + try { + $response = $this->httpClient->send($request); + } catch (\Exception $e) { + $this->log->warning( + "HTTP check on $requestURL failed ({msg})", + ['msg' => $e->getMessage()] + ); + return false; + } + + $content = $response->getBody()->getContents(); + return $content == $keyAuthorization; + } + + /** + * Makes a Curl request. + * + * @param string $method The HTTP method to use. Accepting GET, POST and HEAD requests. + * @param string $URL The URL or partial URL to make the request to. + * If it is partial, the baseURL will be prepended. + * @param string $data The body to attach to a POST request. Expected as a JSON encoded string. + * + * @return array Returns an array with the keys 'request', 'header' and 'body'. + */ + private function request($method, $URL, $data = null) + { + if ($this->accountDeactivated) { + throw new LogicException('The account was deactivated. No further requests can be made.'); + } + + $requestURL = preg_match('~^http~', $URL) ? $URL : $this->baseURL . $URL; + + $hdrs = ['Accept' => 'application/json']; + if (!empty($data)) { + $hdrs['Content-Type'] = 'application/jose+json'; + } + + $request = new Request($method, $requestURL, $hdrs, $data); + + try { + $response = $this->httpClient->send($request); + } catch (BadResponseException $e) { + //4xx/5xx failures are not expected and we throw exceptions for them + $msg = "$method $URL failed"; + if ($e->hasResponse()) { + $body = (string)$e->getResponse()->getBody(); + $json = json_decode($body, true); + if (!empty($json) && isset($json['detail'])) { + $msg .= " ({$json['detail']})"; + } + } + throw new RuntimeException($msg, 0, $e); + } catch (GuzzleException $e) { + //@codeCoverageIgnoreStart + throw new RuntimeException("$method $URL failed", 0, $e); + //@codeCoverageIgnoreEnd + } + + //uncomment this to generate a test simulation of this request + //TestResponseGenerator::dumpTestSimulation($method, $requestURL, $response); + + $this->maintainNonce($method, $response); + + return $this->formatResponse($method, $requestURL, $response); + } + + private function formatResponse($method, $requestURL, ResponseInterface $response) + { + $body = $response->getBody(); + + $header = $response->getStatusCode() . ' ' . $response->getReasonPhrase() . "\n"; + $allHeaders = $response->getHeaders(); + foreach ($allHeaders as $name => $values) { + foreach ($values as $value) { + $header .= "$name: $value\n"; + } + } + + $decoded = $body; + if ($response->getHeaderLine('Content-Type') === 'application/json') { + $decoded = json_decode($body, true); + if (!$decoded) { + //@codeCoverageIgnoreStart + throw new RuntimeException('Bad JSON received ' . $body); + //@codeCoverageIgnoreEnd + } + } + + $jsonresponse = [ + 'request' => $method . ' ' . $requestURL, + 'header' => $header, + 'body' => $decoded, + 'raw' => $body, + 'status' => $response->getStatusCode() + ]; + + //$this->log->debug('{request} got {status} header = {header} body = {raw}', $jsonresponse); + + return $jsonresponse; + } + + private function maintainNonce($requestMethod, ResponseInterface $response) + { + if ($response->hasHeader('Replay-Nonce')) { + $this->nonce = $response->getHeader('Replay-Nonce')[0]; + $this->log->debug("got new nonce " . $this->nonce); + } elseif ($requestMethod == 'POST') { + $this->getNewNonce(); // Not expecting a new nonce with GET and HEAD requests. + } + } + + /** + * Makes a GET request. + * + * @param string $url The URL or partial URL to make the request to. + * If it is partial, the baseURL will be prepended. + * + * @return array Returns an array with the keys 'request', 'header' and 'body'. + */ + public function get($url) + { + return $this->request('GET', $url); + } + + /** + * Makes a POST request. + * + * @param string $url The URL or partial URL for the request to. If it is partial, the baseURL will be prepended. + * @param string $data The body to attach to a POST request. Expected as a json string. + * + * @return array Returns an array with the keys 'request', 'header' and 'body'. + */ + public function post($url, $data = null) + { + return $this->request('POST', $url, $data); + } + + /** + * Makes a HEAD request. + * + * @param string $url The URL or partial URL to make the request to. + * If it is partial, the baseURL will be prepended. + * + * @return array Returns an array with the keys 'request', 'header' and 'body'. + */ + public function head($url) + { + return $this->request('HEAD', $url); + } + + /** + * Generates a JSON Web Key signature to attach to the request. + * + * @param array|string $payload The payload to add to the signature. + * @param string $url The URL to use in the signature. + * @param string $privateKey The private key to sign the request with. + * + * @return string Returns a JSON encoded string containing the signature. + */ + public function signRequestJWK($payload, $url, $privateKey = '') + { + if ($privateKey == '') { + $privateKey = $this->storage->getAccountPrivateKey(); + } + $privateKey = openssl_pkey_get_private($privateKey); + if ($privateKey === false) { + //@codeCoverageIgnoreStart + throw new RuntimeException('LEConnector::signRequestJWK failed to get private key'); + //@codeCoverageIgnoreEnd + } + + $details = openssl_pkey_get_details($privateKey); + + $protected = [ + "alg" => "RS256", + "jwk" => [ + "kty" => "RSA", + "n" => LEFunctions::base64UrlSafeEncode($details["rsa"]["n"]), + "e" => LEFunctions::base64UrlSafeEncode($details["rsa"]["e"]), + ], + "nonce" => $this->nonce, + "url" => $url + ]; + + $payload64 = LEFunctions::base64UrlSafeEncode( + str_replace('\\/', '/', is_array($payload) ? json_encode($payload) : $payload) + ); + $protected64 = LEFunctions::base64UrlSafeEncode(json_encode($protected)); + + openssl_sign($protected64 . '.' . $payload64, $signed, $privateKey, OPENSSL_ALGO_SHA256); + $signed64 = LEFunctions::base64UrlSafeEncode($signed); + + $data = [ + 'protected' => $protected64, + 'payload' => $payload64, + 'signature' => $signed64 + ]; + + return json_encode($data); + } + + /** + * Generates a Key ID signature to attach to the request. + * + * @param array|string $payload The payload to add to the signature. + * @param string $kid The Key ID to use in the signature. + * @param string $url The URL to use in the signature. + * @param string $privateKey The private key to sign the request with. Defaults to account key + * + * @return string Returns a JSON encoded string containing the signature. + */ + public function signRequestKid($payload, $kid, $url, $privateKey = '') + { + if ($privateKey == '') { + $privateKey = $this->storage->getAccountPrivateKey(); + } + $privateKey = openssl_pkey_get_private($privateKey); + + //$details = openssl_pkey_get_details($privateKey); + + $protected = [ + "alg" => "RS256", + "kid" => $kid, + "nonce" => $this->nonce, + "url" => $url + ]; + + $payload64 = LEFunctions::base64UrlSafeEncode( + str_replace('\\/', '/', is_array($payload) ? json_encode($payload) : $payload) + ); + $protected64 = LEFunctions::base64UrlSafeEncode(json_encode($protected)); + + openssl_sign($protected64 . '.' . $payload64, $signed, $privateKey, OPENSSL_ALGO_SHA256); + $signed64 = LEFunctions::base64UrlSafeEncode($signed); + + $data = [ + 'protected' => $protected64, + 'payload' => $payload64, + 'signature' => $signed64 + ]; + + return json_encode($data); + } +} diff --git a/src/LEFunctions.php b/src/LEFunctions.php new file mode 100644 index 0000000..b1d7af6 --- /dev/null +++ b/src/LEFunctions.php @@ -0,0 +1,119 @@ + + * @copyright 2018 Youri van Weegberg + * @license https://opensource.org/licenses/mit-license.php MIT License + */ +class LEFunctions +{ + /** + * Generates a new RSA keypair and returns both + * + * @param integer $keySize RSA key size, must be between 2048 and 4096 (default is 4096) + * @return array containing public and private indexes containing the new keys + */ + public static function RSAGenerateKeys($keySize = 4096) + { + + if ($keySize < 2048 || $keySize > 4096) { + throw new LogicException("RSA key size must be between 2048 and 4096"); + } + + $res = openssl_pkey_new([ + "private_key_type" => OPENSSL_KEYTYPE_RSA, + "private_key_bits" => intval($keySize), + ]); + + if (!openssl_pkey_export($res, $privateKey)) { + throw new RuntimeException("RSA keypair export failed!"); //@codeCoverageIgnore + } + + $details = openssl_pkey_get_details($res); + + $result = ['public' => $details['key'], 'private' => $privateKey]; + + openssl_pkey_free($res); + + return $result; + } + + + /** + * Generates a new EC prime256v1 keypair and saves both keys to a new file. + * + * @param integer $keySize EC key size, possible values are 256 (prime256v1) or 384 (secp384r1), + * default is 256 + * @return array containing public and private indexes containing the new keys + */ + public static function ECGenerateKeys($keySize = 256) + { + if (version_compare(PHP_VERSION, '7.1.0') == -1) { + throw new RuntimeException("PHP 7.1+ required for EC keys"); //@codeCoverageIgnore + } + + if ($keySize == 256) { + $res = openssl_pkey_new([ + "private_key_type" => OPENSSL_KEYTYPE_EC, + "curve_name" => "prime256v1", + ]); + } elseif ($keySize == 384) { + $res = openssl_pkey_new([ + "private_key_type" => OPENSSL_KEYTYPE_EC, + "curve_name" => "secp384r1", + ]); + } else { + throw new LogicException("EC key size must be 256 or 384"); + } + + + if (!openssl_pkey_export($res, $privateKey)) { + throw new RuntimeException("EC keypair export failed!"); //@codeCoverageIgnore + } + + $details = openssl_pkey_get_details($res); + + $result = ['public' => $details['key'], 'private' => $privateKey]; + + openssl_pkey_free($res); + + return $result; + } + + + /** + * Encodes a string input to a base64 encoded string which is URL safe. + * + * @param string $input The input string to encode. + * + * @return string Returns a URL safe base64 encoded string. + */ + public static function base64UrlSafeEncode($input) + { + return str_replace('=', '', strtr(base64_encode($input), '+/', '-_')); + } + + /** + * Decodes a string that is URL safe base64 encoded. + * + * @param string $input The encoded input string to decode. + * + * @return string Returns the decoded input string. + */ + public static function base64UrlSafeDecode($input) + { + $remainder = strlen($input) % 4; + if ($remainder) { + $padlen = 4 - $remainder; + $input .= str_repeat('=', $padlen); + } + return base64_decode(strtr($input, '-_', '+/')); + } +} diff --git a/src/LEOrder.php b/src/LEOrder.php new file mode 100755 index 0000000..9d74521 --- /dev/null +++ b/src/LEOrder.php @@ -0,0 +1,810 @@ + + * @copyright 2018 Youri van Weegberg + * @license https://opensource.org/licenses/mit-license.php MIT License + */ +class LEOrder +{ + const CHALLENGE_TYPE_HTTP = 'http-01'; + const CHALLENGE_TYPE_DNS = 'dns-01'; + + /** @var string order status (pending, processing, valid) */ + private $status; + + /** @var string expiration date for order */ + private $expires; + + /** @var array containing all the domain identifiers for the order */ + private $identifiers; + + /** @var string[] URLs to all the authorization objects for this order */ + private $authorizationURLs; + + /** @var LEAuthorization[] array of authorization objects for the order */ + private $authorizations; + + /** @var string URL for order finalization */ + private $finalizeURL; + + /** @var string URL for obtaining certificate */ + private $certificateURL; + + /** @var string base domain name for certificate */ + private $basename; + + /** @var string URL referencing order */ + private $orderURL; + + /** @var string type of key (rsa or ec) */ + private $keyType; + + /** @var int size of key (typically 2048 or 4096 for rsa, 256 or 384 for ec */ + private $keySize; + + /** @var LEConnector ACME API connection provided to constructor */ + private $connector; + + /** @var LoggerInterface logger provided to constructor */ + private $log; + + /** @var DNSValidatorInterface dns resolution provider to constructor*/ + private $dns; + + /** @var Sleep sleep service provided to constructor */ + private $sleep; + + /** @var CertificateStorageInterface storage interface provided to constructor */ + private $storage; + + /** + * Initiates the LetsEncrypt Order class. If the base name is found in the $keysDir directory, the order data is + * requested. If no order was found locally, if the request is invalid or when there is a change in domain names, a + * new order is created. + * + * @param LEConnector $connector The LetsEncrypt Connector instance to use for HTTP requests. + * @param CertificateStorageInterface $storage + * @param LoggerInterface $log PSR-3 compatible logger + * @param DNSValidatorInterface $dns DNS challenge checking service + * @param Sleep $sleep Sleep service for polling + */ + public function __construct( + LEConnector $connector, + CertificateStorageInterface $storage, + LoggerInterface $log, + DNSValidatorInterface $dns, + Sleep $sleep + ) { + + $this->connector = $connector; + $this->log = $log; + $this->dns = $dns; + $this->sleep = $sleep; + $this->storage = $storage; + } + + /** + * Loads or updates an order. If the base name is found in the $keysDir directory, the order data is + * requested. If no order was found locally, if the request is invalid or when there is a change in domain names, a + * new order is created. + * + * @param string $basename The base name for the order. Preferable the top domain (example.org). + * Will be the directory in which the keys are stored. Used for the + * CommonName in the certificate as well. + * @param array $domains The array of strings containing the domain names on the certificate. + * @param string $keyType Type of the key we want to use for certificate. Can be provided in + * ALGO-SIZE format (ex. rsa-4096 or ec-256) or simply "rsa" and "ec" + * (using default sizes) + * @param string $notBefore A date string formatted like 0000-00-00T00:00:00Z (yyyy-mm-dd hh:mm:ss) + * at which the certificate becomes valid. + * @param string $notAfter A date string formatted like 0000-00-00T00:00:00Z (yyyy-mm-dd hh:mm:ss) + * until which the certificate is valid. + */ + public function loadOrder($basename, array $domains, $keyType, $notBefore, $notAfter) + { + $this->basename = $basename; + + $this->initialiseKeyTypeAndSize($keyType ?? 'rsa-4096'); + + if ($this->loadExistingOrder($domains)) { + $this->updateAuthorizations(); + } else { + $this->createOrder($domains, $notBefore, $notAfter); + } + } + + private function loadExistingOrder($domains) + { + $orderUrl = $this->storage->getMetadata($this->basename.'.order.url'); + $publicKey = $this->storage->getPublicKey($this->basename); + $privateKey = $this->storage->getPrivateKey($this->basename); + + //anything to load? + if (empty($orderUrl) || empty($publicKey) || empty($privateKey)) { + $this->log->info("No order found for {$this->basename}. Creating new order."); + return false; + } + + //valid URL? + $this->orderURL = $orderUrl; + if (!filter_var($this->orderURL, FILTER_VALIDATE_URL)) { + //@codeCoverageIgnoreStart + $this->log->warning("Order for {$this->basename} has invalid URL. Creating new order."); + $this->deleteOrderFiles(); + return false; + //@codeCoverageIgnoreEnd + } + + //retrieve the order + $get = $this->connector->get($this->orderURL); + if ($get['status'] !== 200) { + //@codeCoverageIgnoreStart + $this->log->warning("Order for {$this->basename} could not be loaded. Creating new order."); + $this->deleteOrderFiles(); + return false; + //@codeCoverageIgnoreEnd + } + + //ensure the order is still valid + if ($get['body']['status'] === 'invalid') { + $this->log->warning("Order for {$this->basename} is 'invalid', unable to authorize. Creating new order."); + $this->deleteOrderFiles(); + return false; + } + + //ensure retrieved order matches our domains + $orderdomains = array_map(function ($ident) { + return $ident['value']; + }, $get['body']['identifiers']); + $diff = array_merge(array_diff($orderdomains, $domains), array_diff($domains, $orderdomains)); + if (!empty($diff)) { + $this->log->warning('Domains do not match order data. Deleting and creating new order.'); + $this->deleteOrderFiles(); + return false; + } + + //the order is good + $this->status = $get['body']['status']; + $this->expires = $get['body']['expires']; + $this->identifiers = $get['body']['identifiers']; + $this->authorizationURLs = $get['body']['authorizations']; + $this->finalizeURL = $get['body']['finalize']; + if (array_key_exists('certificate', $get['body'])) { + $this->certificateURL = $get['body']['certificate']; + } + + return true; + } + + private function deleteOrderFiles() + { + $this->storage->setPrivateKey($this->basename, null); + $this->storage->setPublicKey($this->basename, null); + $this->storage->setCertificate($this->basename, null); + $this->storage->setFullChainCertificate($this->basename, null); + $this->storage->setMetadata($this->basename.'.order.url', null); + } + + private function initialiseKeyTypeAndSize($keyType) + { + if ($keyType == 'rsa') { + $this->keyType = 'rsa'; + $this->keySize = 4096; + } elseif ($keyType == 'ec') { + $this->keyType = 'ec'; + $this->keySize = 256; + } else { + preg_match_all('/^(rsa|ec)\-([0-9]{3,4})$/', $keyType, $keyTypeParts, PREG_SET_ORDER, 0); + + if (!empty($keyTypeParts)) { + $this->keyType = $keyTypeParts[0][1]; + $this->keySize = intval($keyTypeParts[0][2]); + } else { + throw new LogicException('Key type \'' . $keyType . '\' not supported.'); + } + } + } + + /** + * Creates a new LetsEncrypt order and fills this instance with its data. Subsequently creates a new RSA keypair + * for the certificate. + * + * @param array $domains The array of strings containing the domain names on the certificate. + * @param string $notBefore A date string formatted like 0000-00-00T00:00:00Z (yyyy-mm-dd hh:mm:ss) + * at which the certificate becomes valid. + * @param string $notAfter A date string formatted like 0000-00-00T00:00:00Z (yyyy-mm-dd hh:mm:ss) + * until which the certificate is valid. + */ + private function createOrder($domains, $notBefore, $notAfter) + { + if (!preg_match('~(\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}Z|^$)~', $notBefore) || + !preg_match('~(\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}Z|^$)~', $notAfter) + ) { + throw new LogicException("notBefore and notAfter must be blank or iso-8601 datestamp"); + } + + $dns = []; + foreach ($domains as $domain) { + if (preg_match_all('~(\*\.)~', $domain) > 1) { + throw new LogicException('Cannot create orders with multiple wildcards in one domain.'); + } + $dns[] = ['type' => 'dns', 'value' => $domain]; + } + $payload = ["identifiers" => $dns, 'notBefore' => $notBefore, 'notAfter' => $notAfter]; + $sign = $this->connector->signRequestKid( + $payload, + $this->connector->accountURL, + $this->connector->newOrder + ); + $post = $this->connector->post($this->connector->newOrder, $sign); + if ($post['status'] !== 201) { + //@codeCoverageIgnoreStart + throw new RuntimeException('Creating new order failed.'); + //@codeCoverageIgnoreEnd + } + + if (!preg_match('~Location: (\S+)~i', $post['header'], $matches)) { + //@codeCoverageIgnoreStart + throw new RuntimeException('New-order returned invalid response.'); + //@codeCoverageIgnoreEnd + } + + $this->orderURL = trim($matches[1]); + $this->storage->setMetadata($this->basename.'.order.url', $this->orderURL); + + $this->generateKeys(); + + $this->status = $post['body']['status']; + $this->expires = $post['body']['expires']; + $this->identifiers = $post['body']['identifiers']; + $this->authorizationURLs = $post['body']['authorizations']; + $this->finalizeURL = $post['body']['finalize']; + if (array_key_exists('certificate', $post['body'])) { + $this->certificateURL = $post['body']['certificate']; + } + $this->updateAuthorizations(); + + $this->log->info('Created order for ' . $this->basename); + } + + private function generateKeys() + { + if ($this->keyType == "rsa") { + $key = LEFunctions::RSAgenerateKeys($this->keySize); + } else { + $key = LEFunctions::ECgenerateKeys($this->keySize); + } + + $this->storage->setPublicKey($this->basename, $key['public']); + $this->storage->setPrivateKey($this->basename, $key['private']); + } + + /** + * Fetches the latest data concerning this LetsEncrypt Order instance and fills this instance with the new data. + */ + private function updateOrderData() + { + $get = $this->connector->get($this->orderURL); + if (strpos($get['header'], "200 OK") !== false) { + $this->status = $get['body']['status']; + $this->expires = $get['body']['expires']; + $this->identifiers = $get['body']['identifiers']; + $this->authorizationURLs = $get['body']['authorizations']; + $this->finalizeURL = $get['body']['finalize']; + if (array_key_exists('certificate', $get['body'])) { + $this->certificateURL = $get['body']['certificate']; + } + $this->updateAuthorizations(); + } else { + //@codeCoverageIgnoreStart + $this->log->error("Failed to fetch order for {$this->basename}"); + //@codeCoverageIgnoreEnd + } + } + + /** + * Fetches the latest data concerning all authorizations connected to this LetsEncrypt Order instance and + * creates and stores a new LetsEncrypt Authorization instance for each one. + */ + private function updateAuthorizations() + { + $this->authorizations = []; + foreach ($this->authorizationURLs as $authURL) { + if (filter_var($authURL, FILTER_VALIDATE_URL)) { + $auth = new LEAuthorization($this->connector, $this->log, $authURL); + if ($auth != false) { + $this->authorizations[] = $auth; + } + } + } + } + + /** + * Walks all LetsEncrypt Authorization instances and returns whether they are all valid (verified). + * + * @return boolean Returns true if all authorizations are valid (verified), returns false if not. + */ + public function allAuthorizationsValid() + { + if (count($this->authorizations) > 0) { + foreach ($this->authorizations as $auth) { + if ($auth->status != 'valid') { + return false; + } + } + return true; + } + return false; + } + + private function loadAccountKey() + { + $keydata = $this->storage->getAccountPrivateKey(); + $privateKey = openssl_pkey_get_private($keydata); + if ($privateKey === false) { + //@codeCoverageIgnoreStart + throw new RuntimeException("Failed load account key"); + //@codeCoverageIgnoreEnd + } + return $privateKey; + } + + + private function loadCertificateKey() + { + $keydata = $this->storage->getPrivateKey($this->basename); + $privateKey = openssl_pkey_get_private($keydata); + if ($privateKey === false) { + //@codeCoverageIgnoreStart + throw new RuntimeException("Failed load certificate key"); + //@codeCoverageIgnoreEnd + } + return $privateKey; + } + + /** + * Get all pending LetsEncrypt Authorization instances and return the necessary data for verification. + * The data in the return object depends on the $type. + * + * @param string $type The type of verification to get. Supporting http-01 and dns-01. + * Supporting LEOrder::CHALLENGE_TYPE_HTTP and LEOrder::CHALLENGE_TYPE_DNS. Throws a Runtime + * Exception when requesting an unknown $type. Keep in mind a wildcard domain authorization only + * accepts LEOrder::CHALLENGE_TYPE_DNS. + * + * @return array|bool Returns an array with verification data if successful, false if not pending LetsEncrypt + * Authorization instances were found. The return array always + * contains 'type' and 'identifier'. For LEOrder::CHALLENGE_TYPE_HTTP, the array contains + * 'filename' and 'content' for necessary the authorization file. + * For LEOrder::CHALLENGE_TYPE_DNS, the array contains 'DNSDigest', which is the content for the + * necessary DNS TXT entry. + */ + + public function getPendingAuthorizations($type) + { + $authorizations = []; + + $privateKey = $this->loadAccountKey(); + $details = openssl_pkey_get_details($privateKey); + + $header = [ + "e" => LEFunctions::base64UrlSafeEncode($details["rsa"]["e"]), + "kty" => "RSA", + "n" => LEFunctions::base64UrlSafeEncode($details["rsa"]["n"]) + + ]; + $digest = LEFunctions::base64UrlSafeEncode(hash('sha256', json_encode($header), true)); + + foreach ($this->authorizations as $auth) { + if ($auth->status == 'pending') { + $challenge = $auth->getChallenge($type); + if ($challenge['status'] == 'pending') { + $keyAuthorization = $challenge['token'] . '.' . $digest; + switch (strtolower($type)) { + case LEOrder::CHALLENGE_TYPE_HTTP: + $authorizations[] = [ + 'type' => LEOrder::CHALLENGE_TYPE_HTTP, + 'identifier' => $auth->identifier['value'], + 'filename' => $challenge['token'], + 'content' => $keyAuthorization + ]; + break; + case LEOrder::CHALLENGE_TYPE_DNS: + $DNSDigest = LEFunctions::base64UrlSafeEncode( + hash('sha256', $keyAuthorization, true) + ); + $authorizations[] = [ + 'type' => LEOrder::CHALLENGE_TYPE_DNS, + 'identifier' => $auth->identifier['value'], + 'DNSDigest' => $DNSDigest + ]; + break; + } + } + } + } + + return count($authorizations) > 0 ? $authorizations : false; + } + + /** + * Sends a verification request for a given $identifier and $type. The function itself checks whether the + * verification is valid before making the request. + * Updates the LetsEncrypt Authorization instances after a successful verification. + * + * @param string $identifier The domain name to verify. + * @param int $type The type of verification. Supporting LEOrder::CHALLENGE_TYPE_HTTP and + * LEOrder::CHALLENGE_TYPE_DNS. + * + * @return boolean Returns true when the verification request was successful, false if not. + */ + public function verifyPendingOrderAuthorization($identifier, $type) + { + $privateKey = $this->loadAccountKey(); + $details = openssl_pkey_get_details($privateKey); + + $header = [ + "e" => LEFunctions::base64UrlSafeEncode($details["rsa"]["e"]), + "kty" => "RSA", + "n" => LEFunctions::base64UrlSafeEncode($details["rsa"]["n"]) + ]; + $digest = LEFunctions::base64UrlSafeEncode(hash('sha256', json_encode($header), true)); + + foreach ($this->authorizations as $auth) { + if ($auth->identifier['value'] == $identifier) { + if ($auth->status == 'pending') { + $challenge = $auth->getChallenge($type); + if ($challenge['status'] == 'pending') { + $keyAuthorization = $challenge['token'] . '.' . $digest; + switch ($type) { + case LEOrder::CHALLENGE_TYPE_HTTP: + return $this->verifyHTTPChallenge($identifier, $challenge, $keyAuthorization, $auth); + case LEOrder::CHALLENGE_TYPE_DNS: + return $this->verifyDNSChallenge($identifier, $challenge, $keyAuthorization, $auth); + } + } + } + } + } + + //f we reach here, the domain identifier given did not match any authorization object + //@codeCoverageIgnoreStart + throw new LogicException("Attempt to verify authorization for identifier $identifier not in order"); + //@codeCoverageIgnoreEnd + } + + private function verifyDNSChallenge($identifier, array $challenge, $keyAuthorization, LEAuthorization $auth) + { + //check it ourselves + $DNSDigest = LEFunctions::base64UrlSafeEncode(hash('sha256', $keyAuthorization, true)); + if (!$this->dns->checkChallenge($identifier, $DNSDigest)) { + $this->log->warning("DNS challenge for $identifier tested, found invalid."); + return false; + } + + //ask LE to check + $sign = $this->connector->signRequestKid( + ['keyAuthorization' => $keyAuthorization], + $this->connector->accountURL, + $challenge['url'] + ); + $post = $this->connector->post($challenge['url'], $sign); + if ($post['status'] !== 200) { + $this->log->warning("DNS challenge for $identifier valid, but failed to post to ACME service"); + return false; + } + + while ($auth->status == 'pending') { + $this->log->notice("DNS challenge for $identifier valid - waiting for confirmation"); + $this->sleep->for(1); + $auth->updateData(); + } + $this->log->notice("DNS challenge for $identifier validated"); + + return true; + } + + private function verifyHTTPChallenge($identifier, array $challenge, $keyAuthorization, LEAuthorization $auth) + { + if (!$this->connector->checkHTTPChallenge($identifier, $challenge['token'], $keyAuthorization)) { + $this->log->warning("HTTP challenge for $identifier tested, found invalid."); + return false; + } + + $sign = $this->connector->signRequestKid( + ['keyAuthorization' => $keyAuthorization], + $this->connector->accountURL, + $challenge['url'] + ); + + $post = $this->connector->post($challenge['url'], $sign); + if ($post['status'] !== 200) { + //@codeCoverageIgnoreStart + $this->log->warning("HTTP challenge for $identifier valid, but failed to post to ACME service"); + return false; + //@codeCoverageIgnoreEnd + } + + while ($auth->status == 'pending') { + $this->log->notice("HTTP challenge for $identifier valid - waiting for confirmation"); + $this->sleep->for(1); + $auth->updateData(); + } + $this->log->notice("HTTP challenge for $identifier validated"); + return true; + } + + /* + * Deactivate an LetsEncrypt Authorization instance. + * + * @param string $identifier The domain name for which the verification should be deactivated. + * + * @return boolean Returns true is the deactivation request was successful, false if not. + */ + /* + public function deactivateOrderAuthorization($identifier) + { + foreach ($this->authorizations as $auth) { + if ($auth->identifier['value'] == $identifier) { + $sign = $this->connector->signRequestKid( + ['status' => 'deactivated'], + $this->connector->accountURL, + $auth->authorizationURL + ); + $post = $this->connector->post($auth->authorizationURL, $sign); + if (strpos($post['header'], "200 OK") !== false) { + $this->log->info('Authorization for \'' . $identifier . '\' deactivated.'); + $this->updateAuthorizations(); + return true; + } + } + } + + $this->log->warning('No authorization found for \'' . $identifier . '\', cannot deactivate.'); + + return false; + } + */ + + /** + * Generates a Certificate Signing Request for the identifiers in the current LetsEncrypt Order instance. + * If possible, the base name will be the certificate common name and all domain names in this LetsEncrypt Order + * instance will be added to the Subject Alternative Names entry. + * + * @return string Returns the generated CSR as string, unprepared for LetsEncrypt. Preparation for the request + * happens in finalizeOrder() + */ + private function generateCSR() + { + $domains = array_map(function ($dns) { + return $dns['value']; + }, $this->identifiers); + + $dn = ["commonName" => $this->calcCommonName($domains)]; + + $san = implode(",", array_map(function ($dns) { + return "DNS:" . $dns; + }, $domains)); + $tmpConf = tmpfile(); + if ($tmpConf === false) { + //@codeCoverageIgnoreStart + throw new RuntimeException('LEOrder::generateCSR failed to create tmp file'); + //@codeCoverageIgnoreEnd + } + $tmpConfMeta = stream_get_meta_data($tmpConf); + $tmpConfPath = $tmpConfMeta["uri"]; + + fwrite( + $tmpConf, + 'HOME = . + RANDFILE = $ENV::HOME/.rnd + [ req ] + default_bits = 4096 + default_keyfile = privkey.pem + distinguished_name = req_distinguished_name + req_extensions = v3_req + [ req_distinguished_name ] + countryName = Country Name (2 letter code) + [ v3_req ] + basicConstraints = CA:FALSE + subjectAltName = ' . $san . ' + keyUsage = nonRepudiation, digitalSignature, keyEncipherment' + ); + + $privateKey = $this->loadCertificateKey(); + $csr = openssl_csr_new($dn, $privateKey, ['config' => $tmpConfPath, 'digest_alg' => 'sha256']); + openssl_csr_export($csr, $csr); + return $csr; + } + + private function calcCommonName($domains) + { + if (in_array($this->basename, $domains)) { + $CN = $this->basename; + } elseif (in_array('*.' . $this->basename, $domains)) { + $CN = '*.' . $this->basename; + } else { + $CN = $domains[0]; + } + return $CN; + } + + /** + * Checks, for redundancy, whether all authorizations are valid, and finalizes the order. Updates this LetsEncrypt + * Order instance with the new data. + * + * @param string $csr The Certificate Signing Request as a string. Can be a custom CSR. If empty, a CSR will + * be generated with the generateCSR() function. + * + * @return boolean Returns true if the finalize request was successful, false if not. + */ + public function finalizeOrder($csr = '') + { + if ($this->status == 'pending') { + if ($this->allAuthorizationsValid()) { + if (empty($csr)) { + $csr = $this->generateCSR(); + } + if (preg_match( + '~-----BEGIN\sCERTIFICATE\sREQUEST-----(.*)-----END\sCERTIFICATE\sREQUEST-----~s', + $csr, + $matches + ) + ) { + $csr = $matches[1]; + } + $csr = trim(LEFunctions::base64UrlSafeEncode(base64_decode($csr))); + $sign = $this->connector->signRequestKid( + ['csr' => $csr], + $this->connector->accountURL, + $this->finalizeURL + ); + $post = $this->connector->post($this->finalizeURL, $sign); + if (strpos($post['header'], "200 OK") !== false) { + $this->status = $post['body']['status']; + $this->expires = $post['body']['expires']; + $this->identifiers = $post['body']['identifiers']; + $this->authorizationURLs = $post['body']['authorizations']; + $this->finalizeURL = $post['body']['finalize']; + if (array_key_exists('certificate', $post['body'])) { + $this->certificateURL = $post['body']['certificate']; + } + $this->updateAuthorizations(); + $this->log->info('Order for \'' . $this->basename . '\' finalized.'); + + return true; + } + } else { + $this->log->warning( + 'Not all authorizations are valid for \'' . + $this->basename . '\'. Cannot finalize order.' + ); + } + } else { + $this->log->warning( + 'Order status for \'' . $this->basename . + '\' is \'' . $this->status . '\'. Cannot finalize order.' + ); + } + return false; + } + + /** + * Gets whether the LetsEncrypt Order is finalized by checking whether the status is processing or valid. Keep in + * mind, a certificate is not yet available when the status still is processing. + * + * @return boolean Returns true if finalized, false if not. + */ + public function isFinalized() + { + return ($this->status == 'processing' || $this->status == 'valid'); + } + + /** + * Requests the certificate for this LetsEncrypt Order instance, after finalization. When the order status is still + * 'processing', the order will be polled max four times with five seconds in between. If the status becomes 'valid' + * in the meantime, the certificate will be requested. Else, the function returns false. + * + * @return boolean Returns true if the certificate is stored successfully, false if the certificate could not be + * retrieved or the status remained 'processing'. + */ + public function getCertificate() + { + $polling = 0; + while ($this->status == 'processing' && $polling < 4) { + $this->log->info('Certificate for ' . $this->basename . ' being processed. Retrying in 5 seconds...'); + + $this->sleep->for(5); + $this->updateOrderData(); + $polling++; + } + + if ($this->status != 'valid' || empty($this->certificateURL)) { + $this->log->warning( + 'Order for ' . $this->basename . ' not valid. Cannot retrieve certificate.' + ); + return false; + } + + $get = $this->connector->get($this->certificateURL); + if (strpos($get['header'], "200 OK") === false) { + $this->log->warning( + 'Invalid response for certificate request for \'' . $this->basename . + '\'. Cannot save certificate.' + ); + return false; + } + + return $this->writeCertificates($get['body']); + } + + + private function writeCertificates($body) + { + if (preg_match_all('~(-----BEGIN\sCERTIFICATE-----[\s\S]+?-----END\sCERTIFICATE-----)~i', $body, $matches)) { + $this->storage->setCertificate($this->basename, $matches[0][0]); + + $matchCount = count($matches[0]); + if ($matchCount > 1) { + $fullchain = $matches[0][0] . "\n"; + + for ($i = 1; $i < $matchCount; $i++) { + $fullchain .= $matches[0][$i] . "\n"; + } + $this->storage->setFullChainCertificate($this->basename, $fullchain); + } + $this->log->info("Certificate for {$this->basename} stored"); + return true; + } + + $this->log->error("Received invalid certificate for {$this->basename}, cannot save"); + return false; + } + + /** + * Revokes the certificate in the current LetsEncrypt Order instance, if existent. Unlike stated in the ACME draft, + * the certificate revoke request cannot be signed with the account private key, and will be signed with the + * certificate private key. + * + * @param int $reason The reason to revoke the LetsEncrypt Order instance certificate. Possible reasons can be + * found in section 5.3.1 of RFC5280. + * + * @return boolean Returns true if the certificate was successfully revoked, false if not. + */ + public function revokeCertificate($reason = 0) + { + if ($this->status != 'valid') { + $this->log->warning("Order for {$this->basename} not valid, cannot revoke"); + return false; + } + + $certificate = $this->storage->getCertificate($this->basename); + if (empty($certificate)) { + $this->log->warning("Certificate for {$this->basename} not found, cannot revoke"); + return false; + } + + preg_match('~-----BEGIN\sCERTIFICATE-----(.*)-----END\sCERTIFICATE-----~s', $certificate, $matches); + $certificate = trim(LEFunctions::base64UrlSafeEncode(base64_decode(trim($matches[1])))); + + $certificateKey = $this->storage->getPrivateKey($this->basename); + $sign = $this->connector->signRequestJWK( + ['certificate' => $certificate, 'reason' => $reason], + $this->connector->revokeCert, + $certificateKey + ); + //4**/5** responses will throw an exception... + $this->connector->post($this->connector->revokeCert, $sign); + $this->log->info("Certificate for {$this->basename} successfully revoked"); + return true; + } +} diff --git a/src/Sleep.php b/src/Sleep.php new file mode 100644 index 0000000..66a1602 --- /dev/null +++ b/src/Sleep.php @@ -0,0 +1,17 @@ +format('D, j M Y H:i:s e');\n"; + + //store body as heredoc + $body = $response->getBody(); + if (strlen($body)) { + $body = preg_replace('/^/m', ' ', $response->getBody()); + echo " \$body = <<getHeaders(); + foreach ($headers as $name => $values) { + //most headers are single valued + if (count($values) == 1) { + $value = var_export($values[0], true); + } else { + $value = var_export($values, true); + } + + //give date-related headers something current when testing + if (in_array($name, ['Expires', 'Date'])) { + $value = '$now'; + } + + //ensure content length is correct for our simulated body + if ($name == 'Content-Length') { + $value = 'strlen($body)'; + } + + echo " '$name' => " . $value . ",\n"; + } + echo " ];\n"; + + $status=$response->getStatusCode(); + + echo " return new Response($status, \$headers, \$body);\n"; + echo "}\n\n"; + } +} diff --git a/tests/DNSOverHTTPSTest.php b/tests/DNSOverHTTPSTest.php new file mode 100644 index 0000000..6ce4ff8 --- /dev/null +++ b/tests/DNSOverHTTPSTest.php @@ -0,0 +1,39 @@ +markTestIncomplete('Fails on travis'); + $client = new DNSOverHTTPS(DNSOverHTTPS::DNS_GOOGLE); + $output = $client->get('example.com', 1); + $this->assertEquals(0, $output->Status); + } + + public function testGetMozilla() + { + $this->markTestIncomplete('Fails on travis'); + $client = new DNSOverHTTPS(DNSOverHTTPS::DNS_MOZILLA); + $output = $client->get('example.com', 1); + $this->assertEquals(0, $output->Status); + } + + public function testGetCloudflare() + { + $this->markTestIncomplete('Fails on travis'); + $client = new DNSOverHTTPS(DNSOverHTTPS::DNS_CLOUDFLARE); + $output = $client->get('example.com', 1); + $this->assertEquals(0, $output->Status); + } +} diff --git a/tests/DiagnosticLoggerTest.php b/tests/DiagnosticLoggerTest.php new file mode 100644 index 0000000..9d2047d --- /dev/null +++ b/tests/DiagnosticLoggerTest.php @@ -0,0 +1,29 @@ +assertEquals(0, $logger->countLogs('info')); + + $logger->info('hello {noun}', ['noun' => 'world']); + $this->assertEquals(1, $logger->countLogs('info')); + + ob_start(); + $logger->dumpConsole(); + $text = ob_get_clean(); + $this->assertContains('hello world', $text); + + $html=$logger->dumpHTML(false); + $this->assertContains('hello world', $html); + + $logger->cleanLogs(); + $this->assertEquals(0, $logger->countLogs('info')); + } +} diff --git a/tests/FilesystemCertificateStorageTest.php b/tests/FilesystemCertificateStorageTest.php new file mode 100644 index 0000000..b9dbb08 --- /dev/null +++ b/tests/FilesystemCertificateStorageTest.php @@ -0,0 +1,58 @@ +deleteDirectory($dir); + $store = new FilesystemCertificateStorage($dir); + + $this->assertNull($store->getAccountPrivateKey()); + $store->setAccountPrivateKey('abcd1234'); + $this->assertEquals('abcd1234', $store->getAccountPrivateKey()); + + $this->assertNull($store->getAccountPublicKey()); + $store->setAccountPublicKey('efgh2345'); + $this->assertEquals('efgh2345', $store->getAccountPublicKey()); + + $domain='*.example.org'; + $this->assertNull($store->getCertificate($domain)); + $store->setCertificate($domain, 'ijkl3456'); + $this->assertEquals('ijkl3456', $store->getCertificate($domain)); + + $this->assertNull($store->getFullChainCertificate($domain)); + $store->setFullChainCertificate($domain, 'mnop4567'); + $this->assertEquals('mnop4567', $store->getFullChainCertificate($domain)); + + $this->assertNull($store->getPrivateKey($domain)); + $store->setPrivateKey($domain, 'qrst5678'); + $this->assertEquals('qrst5678', $store->getPrivateKey($domain)); + + + $key='banjo'; + $this->assertFalse($store->hasMetadata($key)); + $this->assertNull($store->getMetadata($key)); + $store->setMetadata($key, 'uvwx6789'); + $this->assertTrue($store->hasMetadata($key)); + $this->assertEquals('uvwx6789', $store->getMetadata($key)); + } +} diff --git a/tests/LEAccountTest.php b/tests/LEAccountTest.php new file mode 100644 index 0000000..c99d70a --- /dev/null +++ b/tests/LEAccountTest.php @@ -0,0 +1,155 @@ +prophesize(LEConnector::class); + $connector->newAccount = 'http://test.local/new-account'; + $connector->keyChange = 'http://test.local/change-key'; + + $connector->signRequestKid(Argument::any(), Argument::any(), Argument::any()) + ->willReturn(json_encode(['protected'=>'','payload'=>'','signature'=>''])); + $connector->signRequestJWK(Argument::any(), Argument::any(), Argument::any()) + ->willReturn(json_encode(['protected'=>'','payload'=>'','signature'=>''])); + + $accountUrl='https://acme-staging-v02.api.letsencrypt.org/acme/acct/5757881'; + $newaccount=[]; + $newaccount['header']='201 Created\r\nLocation: '.$accountUrl; + $newaccount['body']=json_decode($this->postNewAccountJSON(), true); + $newaccount['status']=201; + + $connector->post('http://test.local/new-account', Argument::any()) + ->willReturn($newaccount); + + $account=$newaccount; + $account['header']='200 OK\r\nLocation: '.$accountUrl; + $account['status']=200; + $connector->post($accountUrl, Argument::any()) + ->willReturn($account); + + $connector->post('http://test.local/new-account2', Argument::any()) + ->willReturn($account); + + $account['header']='404 Not Found'; + $account['status']=404; + $connector->post('http://test.local/new-account3', Argument::any()) + ->willReturn($account); + + $account=$newaccount; + $account['header']='200 OK\r\n'; + $account['status']=200; + $connector->post($connector->keyChange, Argument::any()) + ->willReturn($account); + + return $connector->reveal(); + } + + protected function initCertStorage() + { + $keyDir=sys_get_temp_dir().'/le-acc-test'; + $this->deleteDirectory($keyDir); + $store = new FilesystemCertificateStorage($keyDir); + return $store; + } + + public function testBasicCreateAndReload() + { + $conn = $this->mockConnector(); + $log = new NullLogger(); + $store = $this->initCertStorage(); + + //at first, should not exist + $this->assertNull($store->getAccountPrivateKey()); + $this->assertNull($store->getAccountPublicKey()); + + new LEAccount($conn, $log, ['test@example.org'], $store); + + $this->assertNotEmpty($store->getAccountPrivateKey()); + $this->assertNotEmpty($store->getAccountPublicKey()); + + //reload for coverage...we need to fudge the mock connection a little + $conn->newAccount = 'http://test.local/new-account2'; + + new LEAccount($conn, $log, ['test@example.org'], $store); + + //it's enough to reach here without exception + $this->assertTrue(true); + } + + /** + * @expectedException RuntimeException + */ + public function testNotFound() + { + $conn = $this->mockConnector(); + $log = new NullLogger(); + $store = $this->initCertStorage(); + + //at first, should not exist + $this->assertNull($store->getAccountPrivateKey()); + $this->assertNull($store->getAccountPublicKey()); + + new LEAccount($conn, $log, ['test@example.org'], $store); + + $this->assertNotEmpty($store->getAccountPrivateKey()); + $this->assertNotEmpty($store->getAccountPublicKey()); + + + //when we reload, we fudge things to get a 404 + $conn->newAccount = 'http://test.local/new-account3'; + + new LEAccount($conn, $log, ['test@example.org'], $store); + } + + public function testUpdateAccount() + { + $conn = $this->mockConnector(); + $log = new NullLogger(); + $store = $this->initCertStorage(); + + $account = new LEAccount($conn, $log, ['test@example.org'], $store); + + $ok = $account->updateAccount(['new@example.org']); + $this->assertTrue($ok); + } + + public function testChangeKeys() + { + $conn = $this->mockConnector(); + $log = new NullLogger(); + $store = $this->initCertStorage(); + + $account = new LEAccount($conn, $log, ['test@example.org'], $store); + + $ok = $account->changeAccountKeys(); + $this->assertTrue($ok); + } + + public function testDeactivate() + { + $conn = $this->mockConnector(); + $log = new NullLogger(); + $store = $this->initCertStorage(); + + $account = new LEAccount($conn, $log, ['test@example.org'], $store); + + $ok = $account->deactivateAccount(); + $this->assertTrue($ok); + } +} diff --git a/tests/LEClientTest.php b/tests/LEClientTest.php new file mode 100644 index 0000000..6689c99 --- /dev/null +++ b/tests/LEClientTest.php @@ -0,0 +1,170 @@ +getDirectoryResponse(), + $this->headNewNonceResponse(), + + //getAccount + $this->postNewAccountResponse(), + $this->postAccountResponse(), + + //getOrCreateOrder + $this->postNewOrderResponse(), + $this->getAuthzResponse('example.org', false), + $this->getAuthzResponse('test.example.org', false), + + //verifyPendingOrderAuthorization + $this->postChallengeResponse(), + $this->getAuthzResponse('example.org', true), + $this->postChallengeResponse(), + $this->getAuthzResponse('test.example.org', true), + + //finalizeOrder + $this->getPostFinalizeResponse(), + $this->getAuthzResponse('example.org', true), + + //getCertificate + $this->getCertResponse(), + + new RequestException("Unexpected request", new Request('GET', 'test')) + ]); + + //mock DNS service which will pretend our challenges have been set + $dns = $this->prophesize(DNSValidatorInterface::class); + $dns->checkChallenge('example.org', Argument::any()) + ->willReturn(true); + $dns->checkChallenge('test.example.org', Argument::any()) + ->willReturn(true); + + //mock sleep service which, erm, won't sleep. Shave a few seconds off tests! + $sleep = $this->prophesize(Sleep::class); + $sleep->for(Argument::any())->willReturn(true); + + $handler = HandlerStack::create($mock); + $httpClient = new Client(['handler' => $handler]); + + $keys = sys_get_temp_dir() . '/le-client-test'; + $this->deleteDirectory($keys); + + $storage = new FilesystemCertificateStorage($keys); + + $client = new LEClient(['test@example.com'], LEClient::LE_STAGING, $logger, $httpClient, $storage); + + //use our DNS and Sleep mocks + $client->setDNS($dns->reveal()); + $client->setSleep($sleep->reveal()); + + // Defining the base name for this order + $basename = 'example.org'; + $domains = ['example.org', 'test.example.org']; + + $order = $client->getOrCreateOrder($basename, $domains); + + //now let's simulate checking a DNS challenge + if (!$order->allAuthorizationsValid()) { + // Get the DNS challenges from the pending authorizations. + $pending = $order->getPendingAuthorizations(LEOrder::CHALLENGE_TYPE_DNS); + // Walk the list of pending authorization DNS challenges. + if (!empty($pending)) { + foreach ($pending as $challenge) { + //now verify the DNS challenage has been fulfilled + $verified = $order->verifyPendingOrderAuthorization( + $challenge['identifier'], + LEOrder::CHALLENGE_TYPE_DNS + ); + $this->assertTrue($verified); + } + } + } + + // at this point, we've simulated that the DNS has been validated + $this->assertTrue($order->allAuthorizationsValid()); + + //but the order is not yet finalized + $this->assertFalse($order->isFinalized()); + + //so let's do it! + $order->finalizeOrder(); + + //should be good now + $this->assertTrue($order->isFinalized()); + + //finally, we can get our cert + $order->getCertificate(); + + //one final test for coverage - get the acount + $account = $client->getAccount(); + $this->assertInstanceOf(LEAccount::class, $account); + } + + public function testBooleanBaseUrl() + { + $logger = new DiagnosticLogger(); + $http = $this->prophesize(Client::class); + $keys = sys_get_temp_dir() . '/le-client-test'; + + $storage = new FilesystemCertificateStorage($keys); + + //this should give us a staging url + $client = new LEClient(['test@example.com'], true, $logger, $http->reveal(), $storage); + $this->assertEquals(LEClient::LE_STAGING, $client->getBaseUrl()); + + //and this should be production + $client = new LEClient(['test@example.com'], false, $logger, $http->reveal(), $storage); + $this->assertEquals(LEClient::LE_PRODUCTION, $client->getBaseUrl()); + } + + /** + * @expectedException LogicException + */ + public function testInvalidBaseUrl() + { + $logger = new DiagnosticLogger(); + $http = $this->prophesize(Client::class); + $keys = sys_get_temp_dir() . '/le-client-test'; + + $storage = new FilesystemCertificateStorage($keys); + + //this should give us a staging url + new LEClient(['test@example.com'], [], $logger, $http->reveal(), $storage); + } + + public function testArrayKey() + { + $logger = new DiagnosticLogger(); + $http = $this->prophesize(Client::class); + + $dir = sys_get_temp_dir() . '/le-client-test'; + $this->deleteDirectory($dir); + + $storage = new FilesystemCertificateStorage($dir); + + $client = new LEClient(['test@example.com'], true, $logger, $http->reveal(), $storage); + //it's enough to reach here without exceptions + $this->assertNotNull($client); + } +} diff --git a/tests/LEConnectorTest.php b/tests/LEConnectorTest.php new file mode 100644 index 0000000..a8c53b0 --- /dev/null +++ b/tests/LEConnectorTest.php @@ -0,0 +1,174 @@ +deleteDirectory($keys); + return new FilesystemCertificateStorage($keys); + } + + public function testConstructor() + { + $logger=new DiagnosticLogger(); + + // when the LEConnector is constructed, it requests the directory and get a new nonce, so + // we set that up here + $mock = new MockHandler([ + $this->getDirectoryResponse(), + $this->headNewNonceResponse(), + new RequestException("Unexpected request", new Request('GET', 'test')) + ]); + + $handler = HandlerStack::create($mock); + $client = new Client(['handler' => $handler]); + $store = $this->prepareKeysStorage(); + + $connector = new LEConnector($logger, $client, 'https://acme-staging-v02.api.letsencrypt.org', $store); + + //it's enough to reach here without getting any exceptions + $this->assertNotNull($connector); + } + + + /** + * @expectedException RuntimeException + */ + public function testBadRequest() + { + $logger=new DiagnosticLogger(); + + // when the LEConnector is constructed, it requests the directory and get a new nonce, so + // we set that up here + $mock = new MockHandler([ + $this->getMissingResponse(), + new RequestException("Unexpected request", new Request('GET', 'test')) + ]); + + $handler = HandlerStack::create($mock); + $client = new Client(['handler' => $handler]); + $store = $this->prepareKeysStorage(); + + new LEConnector($logger, $client, 'https://acme-staging-v02.api.letsencrypt.org', $store); + } + + /** + * @expectedException LogicException + */ + public function testDeactivated() + { + $logger=new DiagnosticLogger(); + + // when the LEConnector is constructed, it requests the directory and get a new nonce, so + // we set that up here + $mock = new MockHandler([ + $this->getDirectoryResponse(), + $this->headNewNonceResponse(), + new RequestException("Unexpected request", new Request('GET', 'test')) + ]); + + $handler = HandlerStack::create($mock); + $client = new Client(['handler' => $handler]); + $store = $this->prepareKeysStorage(); + + + $connector = new LEConnector($logger, $client, 'https://acme-staging-v02.api.letsencrypt.org', $store); + + //deactivation isn't persisted, its just a flag to prevent further API calls in the same session + $connector->accountDeactivated = true; + + $connector->get("https://acme-staging-v02.api.letsencrypt.org/acme/new-acct"); + } + + /** + * Just for coverage, this checks that if guzzle throws some kind of internal failure, we + * in turn throw a RuntimeException + * @expectedException RuntimeException + */ + public function testGuzzleException() + { + $logger=new DiagnosticLogger(); + $mock = new MockHandler([ + new TransferException("Guzzle failure") + ]); + + $handler = HandlerStack::create($mock); + $client = new Client(['handler' => $handler]); + $store = $this->prepareKeysStorage(); + + + new LEConnector($logger, $client, 'https://acme-staging-v02.api.letsencrypt.org', $store); + } + + public function testSignRequestJWK() + { + $logger=new DiagnosticLogger(); + + // when the LEConnector is constructed, it requests the directory and get a new nonce, so + // we set that up here + $mock = new MockHandler([ + $this->getDirectoryResponse(), + $this->headNewNonceResponse(), + new RequestException("Unexpected request", new Request('GET', 'test')) + ]); + + $handler = HandlerStack::create($mock); + $client = new Client(['handler' => $handler]); + $store = $this->prepareKeysStorage(); + + //build some keys + $accKeys = LEFunctions::RSAgenerateKeys(2048); + $store->setAccountPrivateKey($accKeys['private']); + $store->setAccountPublicKey($accKeys['public']); + + $connector = new LEConnector($logger, $client, 'https://acme-staging-v02.api.letsencrypt.org', $store); + + $json = $connector->signRequestJWK(['test'=>'foo'], 'http://example.org'); + $data = json_decode($json, true); + $this->assertArrayHasKey('protected', $data); + $this->assertArrayHasKey('payload', $data); + $this->assertArrayHasKey('signature', $data); + } + + public function testSignRequestKid() + { + $logger=new DiagnosticLogger(); + + // when the LEConnector is constructed, it requests the directory and get a new nonce, so + // we set that up here + $mock = new MockHandler([ + $this->getDirectoryResponse(), + $this->headNewNonceResponse(), + new RequestException("Unexpected request", new Request('GET', 'test')) + ]); + + $handler = HandlerStack::create($mock); + $client = new Client(['handler' => $handler]); + $store = $this->prepareKeysStorage(); + + //build some keys + $accKeys = LEFunctions::RSAgenerateKeys(2048); + $store->setAccountPrivateKey($accKeys['private']); + $store->setAccountPublicKey($accKeys['public']); + + $connector = new LEConnector($logger, $client, 'https://acme-staging-v02.api.letsencrypt.org', $store); + + $json = $connector->signRequestKid(['test'=>'foo'], '1234', 'http://example.org'); + $data = json_decode($json, true); + $this->assertArrayHasKey('protected', $data); + $this->assertArrayHasKey('payload', $data); + $this->assertArrayHasKey('signature', $data); + } +} diff --git a/tests/LEFunctionsTest.php b/tests/LEFunctionsTest.php new file mode 100644 index 0000000..451d5eb --- /dev/null +++ b/tests/LEFunctionsTest.php @@ -0,0 +1,77 @@ +assertArrayHasKey('public', $keys); + $this->assertArrayHasKey('private', $keys); + $this->assertContains('BEGIN PUBLIC KEY', $keys['public']); + $this->assertContains('BEGIN PRIVATE KEY', $keys['private']); + } + + /** + * @expectedException LogicException + */ + public function testRSAGenerateKeysWithInvalidLength() + { + LEFunctions::RSAGenerateKeys(111); + } + + /** + * @dataProvider ecKeyLengthProvider + */ + public function testECGenerateKeys($length) + { + if (version_compare(PHP_VERSION, '7.1.0') == -1) { + $this->markTestSkipped('PHP 7.1+ required for EC keys'); + } + $keys = LEFunctions::ECGenerateKeys($length); + + $this->assertArrayHasKey('public', $keys); + $this->assertArrayHasKey('private', $keys); + $this->assertContains('BEGIN PUBLIC KEY', $keys['public']); + $this->assertContains('BEGIN EC PRIVATE KEY', $keys['private']); + } + + public function ecKeyLengthProvider() + { + return [[256], [384]]; + } + + /** + * @expectedException LogicException + */ + public function testECGenerateKeysWithInvalidLength() + { + if (version_compare(PHP_VERSION, '7.1.0') == -1) { + $this->markTestSkipped('PHP 7.1+ required for EC keys'); + } + + LEFunctions::ECGenerateKeys(111); + } + + + public function testBase64() + { + $encoded = LEFunctions::base64UrlSafeEncode('frumious~bandersnatch!'); + $this->assertEquals('ZnJ1bWlvdXN-YmFuZGVyc25hdGNoIQ', $encoded); + + $plain = LEFunctions::base64UrlSafeDecode($encoded); + $this->assertEquals('frumious~bandersnatch!', $plain); + } + + private function rm($file) + { + if (file_exists($file)) { + unlink($file); + } + } +} diff --git a/tests/LEOrderECTest.php b/tests/LEOrderECTest.php new file mode 100644 index 0000000..a2422da --- /dev/null +++ b/tests/LEOrderECTest.php @@ -0,0 +1,732 @@ +markTestSkipped('PHP 7.1+ required for EC keys'); + } + + parent::setUp(); + } + + /** + * @return LEConnector + */ + private function mockConnector($orderValid = false, $authValid = true, $orderStatus = null) + { + if (is_null($orderStatus)) { + $orderStatus = $orderValid ? 'valid' : 'pending'; + } + + $connector = $this->prophesize(LEConnector::class); + $connector->newOrder = 'http://test.local/new-order'; + + $connector->checkHTTPChallenge(Argument::any(), Argument::any(), Argument::any()) + ->willReturn(true); + + $connector->signRequestKid(Argument::any(), Argument::any(), Argument::any()) + ->willReturn(json_encode(['protected'=>'','payload'=>'','signature'=>''])); + + $neworder=[]; + $neworder['header']='201 Created\r\nLocation: http://test.local/order/test'; + $neworder['body']=json_decode($this->getOrderJSON($orderStatus), true); + $neworder['status']=201; + + $connector->post('http://test.local/new-order', Argument::any()) + ->willReturn($neworder); + + $authz1=[]; + $authz1['header']='200 OK'; + $authz1['status']=200; + $authz1['body']=json_decode($this->getAuthzJSON('example.org', $authValid), true); + $connector->get( + 'https://acme-staging-v02.api.letsencrypt.org/acme/authz/X2QaFXwrBz7VlN6zdKgm_jmiBctwVZgMZXks4YhfPng', + Argument::any() + )->willReturn($authz1); + + $authz2=[]; + $authz2['header']='200 OK'; + $authz2['status']=200; + $authz2['body']=json_decode($this->getAuthzJSON('test.example.org', $authValid), true); + $connector->get( + 'https://acme-staging-v02.api.letsencrypt.org/acme/authz/WDMI8oX6avFT_rEBfh-ZBMdZs3S-7li2l5gRrps4MXM', + Argument::any() + )->willReturn($authz2); + + $orderReq=[]; + $orderReq['header']='200 OK'; + $orderReq['status']=200; + $orderReq['body']=json_decode($this->getOrderJSON($orderStatus), true); + $connector->get("http://test.local/order/test")->willReturn($orderReq); + + //simulate challenge URLs + foreach ($authz1['body']['challenges'] as $challenge) { + $url=$challenge['url']; + $connector->post($url, Argument::any())->willReturn(['status'=>200]); + } + foreach ($authz2['body']['challenges'] as $challenge) { + $url=$challenge['url']; + $connector->post($url, Argument::any())->willReturn(['status'=>200]); + } + + + return $connector->reveal(); + } + + protected function initCertStore() : CertificateStorageInterface + { + $keyDir=sys_get_temp_dir().'/le-order-test'; + $this->deleteDirectory($keyDir); + + $store = new FilesystemCertificateStorage($keyDir); + $this->addAccountKey($store); + + return $store; + } + + protected function addAccountKey(CertificateStorageInterface $store) + { + $public=<<setAccountPublicKey($public); + $store->setAccountPrivateKey($private); + } + + public function testBasicCreateAndReload() + { + $conn = $this->mockConnector(); + $log = new NullLogger(); + $dns = $this->mockDNS(true); + $sleep = $this->mockSleep(); + $store = $this->initCertStore(); + $basename='example.org'; + $domains = ['example.org', 'test.example.org']; + $keyType = 'ec'; + $notBefore = ''; + $notAfter = ''; + + $this->assertNull($store->getPublicKey($basename)); + + //this should create a new order + $order = new LEOrder($conn, $store, $log, $dns, $sleep); + $order->loadOrder($basename, $domains, $keyType, $notBefore, $notAfter); + + $this->assertNotEmpty($store->getPublicKey($basename)); + + + //if we construct again, it should load the existing order + $order = new LEOrder($conn, $store, $log, $dns, $sleep); + $order->loadOrder($basename, $domains, $keyType, $notBefore, $notAfter); + + //it's enough to reach here without getting any exceptions + $this->assertNotNull($order); + } + + public function testCreateWithValidatedOrder() + { + //our connector will return an order with a certificate url + $conn = $this->mockConnector(true); + $log = new NullLogger(); + $dns = $this->mockDNS(true); + $sleep = $this->mockSleep(); + $store = $this->initCertStore(); + $basename='example.org'; + $domains = ['example.org', 'test.example.org']; + $keyType = 'ec'; + $notBefore = ''; + $notAfter = ''; + + $this->assertNull($store->getPublicKey($basename)); + + //this should create a new order + $order = new LEOrder($conn, $store, $log, $dns, $sleep); + $order->loadOrder($basename, $domains, $keyType, $notBefore, $notAfter); + + //and reload the validated order for coverage! + $order = new LEOrder($conn, $store, $log, $dns, $sleep); + $order->loadOrder($basename, $domains, $keyType, $notBefore, $notAfter); + + //it's enough to reach here without getting any exceptions + $this->assertNotNull($order); + } + + + public function testCreateWithInvalidOrder() + { + //our connector will return an order with a certificate url + $conn = $this->mockConnector(true); + $log = new NullLogger(); + $dns = $this->mockDNS(true); + $sleep = $this->mockSleep(); + $store = $this->initCertStore(); + $basename='example.org'; + $domains = ['example.org', 'test.example.org']; + $keyType = 'ec'; + $notBefore = ''; + $notAfter = ''; + + $this->assertNull($store->getPublicKey($basename)); + + //this should create a new order + $order = new LEOrder($conn, $store, $log, $dns, $sleep); + $order->loadOrder($basename, $domains, $keyType, $notBefore, $notAfter); + + //now we want to load the same order, but we're goign to make it invalid + $conn = $this->mockConnector(true, true, 'invalid'); + + $order = new LEOrder($conn, $store, $log, $dns, $sleep); + $order->loadOrder($basename, $domains, $keyType, $notBefore, $notAfter); + + //it's enough to reach here without getting any exceptions - we could do with a better mock for this + //test as the invalid order will get replaced with a fresh one (but also invalid!) + $this->assertNotNull($order); + } + + + + public function testHttpAuthorizations() + { + //our connector will return an order with a certificate url + $conn = $this->mockConnector(true, false); + $log = new NullLogger(); + $dns = $this->mockDNS(true); + $sleep = $this->mockSleep(); + $store = $this->initCertStore(); + + $basename='example.org'; + $domains = ['example.org', 'test.example.org']; + $keyType = 'ec'; + $notBefore = ''; + $notAfter = ''; + + $this->assertNull($store->getPublicKey($basename)); + + //this should create a new order + $order = new LEOrder($conn, $store, $log, $dns, $sleep); + $order->loadOrder($basename, $domains, $keyType, $notBefore, $notAfter); + + //we expect to find some pending http authorizations + $pending = $order->getPendingAuthorizations(LEOrder::CHALLENGE_TYPE_HTTP); + $this->assertCount(2, $pending); + + //let's try and verify! + //TODO - we need a more sophisticated mock here to return a valid challenge + //$order->verifyPendingOrderAuthorization($basename, LEOrder::CHALLENGE_TYPE_HTTP); + } + + + public function testMismatchedReload() + { + $conn = $this->mockConnector(); + $log = new NullLogger(); + $dns = $this->mockDNS(true); + $sleep = $this->mockSleep(); + $store = $this->initCertStore(); + $basename='example.org'; + $domains = ['example.org', 'test.example.org']; + $keyType = 'ec'; + $notBefore = ''; + $notAfter = ''; + + $this->assertNull($store->getPublicKey($basename)); + + //this should create a new order + $order = new LEOrder($conn, $store, $log, $dns, $sleep); + $order->loadOrder($basename, $domains, $keyType, $notBefore, $notAfter); + + $this->assertNotEmpty($store->getPublicKey($basename)); + + //we construct again to get a reload, but with different domains + $domains = ['example.com', 'test.example.com']; + $order = new LEOrder($conn, $store, $log, $dns, $sleep); + $order->loadOrder($basename, $domains, $keyType, $notBefore, $notAfter); + + //this is allowed - we will just create a new order for the given domains, so it's enough to reach + //here without exception + $this->assertNotNull($order); + } + + + /** + * @expectedException LogicException + */ + public function testCreateWithBadWildcard() + { + $conn = $this->mockConnector(); + $log = new NullLogger(); + $dns = $this->mockDNS(true); + $sleep = $this->mockSleep(); + $store = $this->initCertStore(); + $basename='example.org'; + $domains = ['*.*.example.org']; + $keyType = 'ec'; + $notBefore = ''; + $notAfter = ''; + + $order = new LEOrder($conn, $store, $log, $dns, $sleep); + $order->loadOrder($basename, $domains, $keyType, $notBefore, $notAfter); + } + + /** + * @expectedException LogicException + */ + public function testCreateWithBadKeyType() + { + $conn = $this->mockConnector(); + $log = new NullLogger(); + $dns = $this->mockDNS(true); + $sleep = $this->mockSleep(); + $store = $this->initCertStore(); + $basename='example.org'; + $domains = ['example.org']; + $keyType = 'wibble-4096'; + $notBefore = ''; + $notAfter = ''; + + $order = new LEOrder($conn, $store, $log, $dns, $sleep); + $order->loadOrder($basename, $domains, $keyType, $notBefore, $notAfter); + } + + /** + * @expectedException LogicException + */ + public function testCreateWithBadDates() + { + $conn = $this->mockConnector(); + $log = new NullLogger(); + $dns = $this->mockDNS(true); + $sleep = $this->mockSleep(); + $store = $this->initCertStore(); + $basename='example.org'; + $domains = ['example.org']; + $keyType = 'ec'; + $notBefore = 'Hippopotamus'; + $notAfter = 'Primrose'; + + $order = new LEOrder($conn, $store, $log, $dns, $sleep); + $order->loadOrder($basename, $domains, $keyType, $notBefore, $notAfter); + } + + /** + * @return LEConnector + */ + private function mockConnectorWithNoAuths($valid = false) + { + $connector = $this->prophesize(LEConnector::class); + $connector->newOrder = 'http://test.local/new-order'; + + $connector->signRequestKid(Argument::any(), Argument::any(), Argument::any()) + ->willReturn(json_encode(['protected'=>'','payload'=>'','signature'=>''])); + + $order = json_decode($this->getOrderJSON($valid?'valid':'pending'), true); + $order['authorizations'] = []; + + $neworder=[]; + $neworder['header']='201 Created\r\nLocation: http://test.local/order/test'; + $neworder['status']=201; + $neworder['body']=$order; + + $connector->post('http://test.local/new-order', Argument::any()) + ->willReturn($neworder); + + $orderReq=[]; + $orderReq['header']='200 OK'; + $orderReq['status']=200; + $orderReq['body']=$order; + + $connector->get("http://test.local/order/test")->willReturn($orderReq); + + return $connector->reveal(); + } + + /** + * Covers the case where there are no authorizations in the order + */ + public function testAllAuthorizationsValid() + { + $conn = $this->mockConnectorWithNoAuths(); + $log = new NullLogger(); + $dns = $this->mockDNS(true); + $sleep = $this->mockSleep(); + $store = $this->initCertStore(); + $basename='example.org'; + $domains = ['example.org', 'test.example.org']; + $keyType = 'ec'; + $notBefore = ''; + $notAfter = ''; + + $this->assertNull($store->getPublicKey($basename)); + + //this should create a new order + $order = new LEOrder($conn, $store, $log, $dns, $sleep); + $order->loadOrder($basename, $domains, $keyType, $notBefore, $notAfter); + + $this->assertFalse($order->allAuthorizationsValid()); + } + + /** + * @return LEConnector + */ + private function mockConnectorForProcessingCert($eventuallyValid = true, $goodCertRequest = true, $garbage = false) + { + $valid = true; + + $connector = $this->prophesize(LEConnector::class); + $connector->newOrder = 'http://test.local/new-order'; + $connector->revokeCert = 'http://test.local/revoke-cert'; + + $connector->signRequestKid(Argument::any(), Argument::any(), Argument::any()) + ->willReturn(json_encode(['protected'=>'','payload'=>'','signature'=>''])); + + $connector->signRequestJWK(Argument::any(), Argument::any(), Argument::any()) + ->willReturn(json_encode(['protected'=>'','payload'=>'','signature'=>''])); + + + //the new order is setup to be processing... + $neworder=[]; + $neworder['header']='201 Created\r\nLocation: http://test.local/order/test'; + $neworder['status']=201; + $neworder['body']=json_decode($this->getOrderJSON('valid'), true); + $neworder['body']['status'] = 'processing'; + + $connector->post('http://test.local/new-order', Argument::any()) + ->willReturn($neworder); + + $authz1=[]; + $authz1['header']='200 OK'; + $authz1['status']=200; + $authz1['body']=json_decode($this->getAuthzJSON('example.org', $valid), true); + $connector->get( + 'https://acme-staging-v02.api.letsencrypt.org/acme/authz/X2QaFXwrBz7VlN6zdKgm_jmiBctwVZgMZXks4YhfPng', + Argument::any() + )->willReturn($authz1); + + $authz2=[]; + $authz2['header']='200 OK'; + $authz2['status']=200; + $authz2['body']=json_decode($this->getAuthzJSON('test.example.org', $valid), true); + $connector->get( + 'https://acme-staging-v02.api.letsencrypt.org/acme/authz/WDMI8oX6avFT_rEBfh-ZBMdZs3S-7li2l5gRrps4MXM', + Argument::any() + )->willReturn($authz2); + + //when the order is re-fetched, it's possibly valid + $orderReq=[]; + $orderReq['header']='200 OK'; + $orderReq['status']=200; + $orderReq['body']=json_decode($this->getOrderJSON('valid'), true); + if (!$eventuallyValid) { + $orderReq['body']['status'] = 'processing'; + } + $connector->get('http://test.local/order/test')->willReturn($orderReq); + + $certReq=[]; + $certReq['header']=$goodCertRequest ? '200 OK' : '500 Failed'; + $certReq['status']=200; + $certReq['body']=$garbage ? 'NOT-A-CERT' : $this->getCertBody(); + $connector->get('https://acme-staging-v02.api.letsencrypt.org/acme/cert/fae09c6dcdaf7aa198092b3170c69129a490') + ->willReturn($certReq); + + $revokeReq=[]; + $revokeReq['header']='200 OK'; + $revokeReq['status']=200; + $revokeReq['body']=''; + $connector->post('http://test.local/revoke-cert', Argument::any()) + ->willReturn($revokeReq); + + $connector->post('http://test.local/bad-revoke-cert', Argument::any()) + ->willThrow(new RuntimeException('Revocation failed')); + + return $connector->reveal(); + } + + /** + * Test a certificate fetch with a 'processing' loop in effect + */ + public function testGetCertificate() + { + + $conn = $this->mockConnectorForProcessingCert(true); + $log = new NullLogger(); + $dns = $this->mockDNS(true); + $sleep = $this->mockSleep(); + $store = $this->initCertStore(); + $basename='example.org'; + $domains = ['example.org', 'test.example.org']; + $keyType = 'ec'; + $notBefore = ''; + $notAfter = ''; + + $this->assertNull($store->getPublicKey($basename)); + + //this should create a new order + $order = new LEOrder($conn, $store, $log, $dns, $sleep); + $order->loadOrder($basename, $domains, $keyType, $notBefore, $notAfter); + + $this->assertEmpty($store->getCertificate($basename)); + + $ok = $order->getCertificate(); + $this->assertTrue($ok); + $this->assertNotEmpty($store->getCertificate($basename)); + } + + /** + * Test a certificate fetch with a 'processing' loop in effect + */ + public function testGetCertificateWithValidationDelay() + { + $conn = $this->mockConnectorForProcessingCert(false); + $log = new NullLogger(); + $dns = $this->mockDNS(true); + $sleep = $this->mockSleep(); + $store = $this->initCertStore(); + $basename='example.org'; + $domains = ['example.org', 'test.example.org']; + $keyType = 'ec'; + $notBefore = ''; + $notAfter = ''; + + $this->assertNull($store->getPublicKey($basename)); + + //this should create a new order + $order = new LEOrder($conn, $store, $log, $dns, $sleep); + $order->loadOrder($basename, $domains, $keyType, $notBefore, $notAfter); + + $ok = $order->getCertificate(); + $this->assertFalse($ok); + } + + public function testGetCertificateWithRetrievalFailure() + { + $conn = $this->mockConnectorForProcessingCert(true, false); + $log = new NullLogger(); + $dns = $this->mockDNS(true); + $sleep = $this->mockSleep(); + $store = $this->initCertStore(); + $basename='example.org'; + $domains = ['example.org', 'test.example.org']; + $keyType = 'ec'; + $notBefore = ''; + $notAfter = ''; + + $this->assertNull($store->getPublicKey($basename)); + + //this should create a new order + $order = new LEOrder($conn, $store, $log, $dns, $sleep); + $order->loadOrder($basename, $domains, $keyType, $notBefore, $notAfter); + + $ok = $order->getCertificate(); + $this->assertFalse($ok); + } + + public function testGetCertificateWithGarbageRetrieval() + { + $conn = $this->mockConnectorForProcessingCert(true, true, true); + $log = new NullLogger(); + $dns = $this->mockDNS(true); + $sleep = $this->mockSleep(); + $store = $this->initCertStore(); + $basename='example.org'; + $domains = ['example.org', 'test.example.org']; + $keyType = 'ec'; + $notBefore = ''; + $notAfter = ''; + + $this->assertNull($store->getPublicKey($basename)); + + //this should create a new order + $order = new LEOrder($conn, $store, $log, $dns, $sleep); + $order->loadOrder($basename, $domains, $keyType, $notBefore, $notAfter); + + $ok = $order->getCertificate(); + $this->assertFalse($ok); + } + + public function testRevoke() + { + $conn = $this->mockConnectorForProcessingCert(true); + $log = new NullLogger(); + $dns = $this->mockDNS(true); + $sleep = $this->mockSleep(); + $store = $this->initCertStore(); + $basename='example.org'; + $domains = ['example.org', 'test.example.org']; + $keyType = 'ec'; + $notBefore = ''; + $notAfter = ''; + + $this->assertNull($store->getPublicKey($basename)); + + //this should create a new order + $order = new LEOrder($conn, $store, $log, $dns, $sleep); + $order->loadOrder($basename, $domains, $keyType, $notBefore, $notAfter); + $this->assertTrue($order->getCertificate()); + + $ok = $order->revokeCertificate(); + $this->assertTrue($ok); + } + + public function testRevokeIncompleteOrder() + { + $conn = $this->mockConnector(); + $log = new NullLogger(); + $dns = $this->mockDNS(true); + $sleep = $this->mockSleep(); + $store = $this->initCertStore(); + $basename='example.org'; + $domains = ['example.org', 'test.example.org']; + $keyType = 'ec'; + $notBefore = ''; + $notAfter = ''; + + $this->assertNull($store->getPublicKey($basename)); + + //this should create a new order + $order = new LEOrder($conn, $store, $log, $dns, $sleep); + $order->loadOrder($basename, $domains, $keyType, $notBefore, $notAfter); + + $this->assertNotEmpty($store->getPublicKey($basename)); + + //can't revoke + $ok = $order->revokeCertificate(); + $this->assertFalse($ok); + } + + public function testRevokeMissingCertificate() + { + $conn = $this->mockConnectorForProcessingCert(true); + $log = new NullLogger(); + $dns = $this->mockDNS(true); + $sleep = $this->mockSleep(); + $store = $this->initCertStore(); + $basename='example.org'; + $domains = ['example.org', 'test.example.org']; + $keyType = 'ec'; + $notBefore = ''; + $notAfter = ''; + + $this->assertNull($store->getPublicKey($basename)); + + //this should create a new order + $order = new LEOrder($conn, $store, $log, $dns, $sleep); + $order->loadOrder($basename, $domains, $keyType, $notBefore, $notAfter); + $this->assertTrue($order->getCertificate()); + + //now we're going to remove the cert + $this->assertNotEmpty($store->getCertificate($basename)); + $store->setCertificate($basename, null); + + $ok = $order->revokeCertificate(); + $this->assertFalse($ok); + } + + /** + * @expectedException RuntimeException + */ + public function testRevokeFailure() + { + $conn = $this->mockConnectorForProcessingCert(true); + + //we use an alternate URL for revocation which fails with a 403 + $conn->revokeCert = 'http://test.local/bad-revoke-cert'; + + $log = new NullLogger(); + $dns = $this->mockDNS(true); + $sleep = $this->mockSleep(); + $store = $this->initCertStore(); + + $basename='example.org'; + $domains = ['example.org', 'test.example.org']; + $keyType = 'ec'; + $notBefore = ''; + $notAfter = ''; + + $this->assertNull($store->getPublicKey($basename)); + + //this should create a new order + $order = new LEOrder($conn, $store, $log, $dns, $sleep); + $order->loadOrder($basename, $domains, $keyType, $notBefore, $notAfter); + $this->assertTrue($order->getCertificate()); + + //this should fail as we use a revocation url which simulates failure + $order->revokeCertificate(); + } +} diff --git a/tests/LEOrderRSATest.php b/tests/LEOrderRSATest.php new file mode 100644 index 0000000..77bdbc9 --- /dev/null +++ b/tests/LEOrderRSATest.php @@ -0,0 +1,744 @@ +prophesize(LEConnector::class); + $connector->newOrder = 'http://test.local/new-order'; + + $connector->checkHTTPChallenge(Argument::any(), Argument::any(), Argument::any()) + ->willReturn(true); + + $connector->signRequestKid(Argument::any(), Argument::any(), Argument::any()) + ->willReturn(json_encode(['protected'=>'','payload'=>'','signature'=>''])); + + $neworder=[]; + $neworder['header']='201 Created\r\nLocation: http://test.local/order/test'; + $neworder['body']=json_decode($this->getOrderJSON($orderStatus), true); + $neworder['status']=201; + + $connector->post('http://test.local/new-order', Argument::any()) + ->willReturn($neworder); + + $authz1=[]; + $authz1['header']='200 OK'; + $authz1['status']=200; + $authz1['body']=json_decode($this->getAuthzJSON('example.org', $authValid), true); + $connector->get( + 'https://acme-staging-v02.api.letsencrypt.org/acme/authz/X2QaFXwrBz7VlN6zdKgm_jmiBctwVZgMZXks4YhfPng', + Argument::any() + )->willReturn($authz1); + + $authz2=[]; + $authz2['header']='200 OK'; + $authz2['status']=200; + $authz2['body']=json_decode($this->getAuthzJSON('test.example.org', $authValid), true); + $connector->get( + 'https://acme-staging-v02.api.letsencrypt.org/acme/authz/WDMI8oX6avFT_rEBfh-ZBMdZs3S-7li2l5gRrps4MXM', + Argument::any() + )->willReturn($authz2); + + $orderReq=[]; + $orderReq['header']='200 OK'; + $orderReq['status']=200; + $orderReq['body']=json_decode($this->getOrderJSON($orderStatus), true); + $connector->get("http://test.local/order/test")->willReturn($orderReq); + + //simulate challenge URLs + foreach ($authz1['body']['challenges'] as $challenge) { + $url=$challenge['url']; + $connector->post($url, Argument::any())->willReturn(['status'=>200]); + } + foreach ($authz2['body']['challenges'] as $challenge) { + $url=$challenge['url']; + $connector->post($url, Argument::any())->willReturn(['status'=>200]); + } + + + return $connector->reveal(); + } + + protected function initCertStore() : CertificateStorageInterface + { + $keyDir=sys_get_temp_dir().'/le-order-test'; + $this->deleteDirectory($keyDir); + + $store = new FilesystemCertificateStorage($keyDir); + $this->addAccountKey($store); + + return $store; + } + + protected function addAccountKey(CertificateStorageInterface $store) + { + $public=<<setAccountPublicKey($public); + $store->setAccountPrivateKey($private); + } + + public function testBasicCreateAndReload() + { + $conn = $this->mockConnector(); + $log = new NullLogger(); + $dns = $this->mockDNS(true); + $sleep = $this->mockSleep(); + $store = $this->initCertStore(); + $basename='example.org'; + $domains = ['example.org', 'test.example.org']; + $keyType = 'rsa-4096'; + $notBefore = ''; + $notAfter = ''; + + $this->assertNull($store->getPublicKey($basename)); + + //this should create a new order + $order = new LEOrder($conn, $store, $log, $dns, $sleep); + $order->loadOrder($basename, $domains, $keyType, $notBefore, $notAfter); + + $this->assertNotEmpty($store->getPublicKey($basename)); + + + //if we construct again, it should load the existing order + $order = new LEOrder($conn, $store, $log, $dns, $sleep); + $order->loadOrder($basename, $domains, $keyType, $notBefore, $notAfter); + + //it's enough to reach here without getting any exceptions + $this->assertNotNull($order); + } + + public function testCreateWithValidatedOrder() + { + //our connector will return an order with a certificate url + $conn = $this->mockConnector(true); + $log = new NullLogger(); + $dns = $this->mockDNS(true); + $sleep = $this->mockSleep(); + $store = $this->initCertStore(); + $basename='example.org'; + $domains = ['example.org', 'test.example.org']; + $keyType = 'rsa-4096'; + $notBefore = ''; + $notAfter = ''; + + $this->assertNull($store->getPublicKey($basename)); + + //this should create a new order + $order = new LEOrder($conn, $store, $log, $dns, $sleep); + $order->loadOrder($basename, $domains, $keyType, $notBefore, $notAfter); + + //and reload the validated order for coverage! + $order = new LEOrder($conn, $store, $log, $dns, $sleep); + $order->loadOrder($basename, $domains, $keyType, $notBefore, $notAfter); + + //it's enough to reach here without getting any exceptions + $this->assertNotNull($order); + } + + + public function testCreateWithInvalidOrder() + { + //our connector will return an order with a certificate url + $conn = $this->mockConnector(true); + $log = new NullLogger(); + $dns = $this->mockDNS(true); + $sleep = $this->mockSleep(); + $store = $this->initCertStore(); + $basename='example.org'; + $domains = ['example.org', 'test.example.org']; + $keyType = 'rsa-4096'; + $notBefore = ''; + $notAfter = ''; + + $this->assertNull($store->getPublicKey($basename)); + + //this should create a new order + $order = new LEOrder($conn, $store, $log, $dns, $sleep); + $order->loadOrder($basename, $domains, $keyType, $notBefore, $notAfter); + + //now we want to load the same order, but we're goign to make it invalid + $conn = $this->mockConnector(true, true, 'invalid'); + + $order = new LEOrder($conn, $store, $log, $dns, $sleep); + $order->loadOrder($basename, $domains, $keyType, $notBefore, $notAfter); + + //it's enough to reach here without getting any exceptions - we could do with a better mock for this + //test as the invalid order will get replaced with a fresh one (but also invalid!) + $this->assertNotNull($order); + } + + + + public function testHttpAuthorizations() + { + //our connector will return an order with a certificate url + $conn = $this->mockConnector(true, false); + $log = new NullLogger(); + $dns = $this->mockDNS(true); + $sleep = $this->mockSleep(); + $store = $this->initCertStore(); + + $basename='example.org'; + $domains = ['example.org', 'test.example.org']; + $keyType = 'rsa-4096'; + $notBefore = ''; + $notAfter = ''; + + $this->assertNull($store->getPublicKey($basename)); + + //this should create a new order + $order = new LEOrder($conn, $store, $log, $dns, $sleep); + $order->loadOrder($basename, $domains, $keyType, $notBefore, $notAfter); + + //we expect to find some pending http authorizations + $pending = $order->getPendingAuthorizations(LEOrder::CHALLENGE_TYPE_HTTP); + $this->assertCount(2, $pending); + + //let's try and verify! + //TODO - we need a more sophisticated mock here to return a valid challenge + //$order->verifyPendingOrderAuthorization($basename, LEOrder::CHALLENGE_TYPE_HTTP); + } + + + public function testMismatchedReload() + { + $conn = $this->mockConnector(); + $log = new NullLogger(); + $dns = $this->mockDNS(true); + $sleep = $this->mockSleep(); + $store = $this->initCertStore(); + $basename='example.org'; + $domains = ['example.org', 'test.example.org']; + $keyType = 'rsa-4096'; + $notBefore = ''; + $notAfter = ''; + + $this->assertNull($store->getPublicKey($basename)); + + //this should create a new order + $order = new LEOrder($conn, $store, $log, $dns, $sleep); + $order->loadOrder($basename, $domains, $keyType, $notBefore, $notAfter); + + $this->assertNotEmpty($store->getPublicKey($basename)); + + //we construct again to get a reload, but with different domains + $domains = ['example.com', 'test.example.com']; + $order = new LEOrder($conn, $store, $log, $dns, $sleep); + $order->loadOrder($basename, $domains, $keyType, $notBefore, $notAfter); + + //this is allowed - we will just create a new order for the given domains, so it's enough to reach + //here without exception + $this->assertNotNull($order); + } + + + /** + * @expectedException LogicException + */ + public function testCreateWithBadWildcard() + { + $conn = $this->mockConnector(); + $log = new NullLogger(); + $dns = $this->mockDNS(true); + $sleep = $this->mockSleep(); + $store = $this->initCertStore(); + $basename='example.org'; + $domains = ['*.*.example.org']; + $keyType = 'rsa-4096'; + $notBefore = ''; + $notAfter = ''; + + $order = new LEOrder($conn, $store, $log, $dns, $sleep); + $order->loadOrder($basename, $domains, $keyType, $notBefore, $notAfter); + } + + /** + * @expectedException LogicException + */ + public function testCreateWithBadKeyType() + { + $conn = $this->mockConnector(); + $log = new NullLogger(); + $dns = $this->mockDNS(true); + $sleep = $this->mockSleep(); + $store = $this->initCertStore(); + $basename='example.org'; + $domains = ['example.org']; + $keyType = 'wibble-4096'; + $notBefore = ''; + $notAfter = ''; + + $order = new LEOrder($conn, $store, $log, $dns, $sleep); + $order->loadOrder($basename, $domains, $keyType, $notBefore, $notAfter); + } + + /** + * @expectedException LogicException + */ + public function testCreateWithBadDates() + { + $conn = $this->mockConnector(); + $log = new NullLogger(); + $dns = $this->mockDNS(true); + $sleep = $this->mockSleep(); + $store = $this->initCertStore(); + $basename='example.org'; + $domains = ['example.org']; + $keyType = 'rsa'; + $notBefore = 'Hippopotamus'; + $notAfter = 'Primrose'; + + $order = new LEOrder($conn, $store, $log, $dns, $sleep); + $order->loadOrder($basename, $domains, $keyType, $notBefore, $notAfter); + } + + public function testCreateWithEC() + { + $conn = $this->mockConnector(); + $log = new NullLogger(); + $dns = $this->mockDNS(true); + $sleep = $this->mockSleep(); + $store = $this->initCertStore(); + $basename='example.org'; + $domains = ['example.org', 'test.example.org']; + $keyType = 'rsa'; + $notBefore = ''; + $notAfter = ''; + + $this->assertNull($store->getPublicKey($basename)); + + //this should create a new order + $order = new LEOrder($conn, $store, $log, $dns, $sleep); + $order->loadOrder($basename, $domains, $keyType, $notBefore, $notAfter); + + $this->assertNotEmpty($store->getPublicKey($basename)); + } + + /** + * @return LEConnector + */ + private function mockConnectorWithNoAuths($valid = false) + { + $connector = $this->prophesize(LEConnector::class); + $connector->newOrder = 'http://test.local/new-order'; + + $connector->signRequestKid(Argument::any(), Argument::any(), Argument::any()) + ->willReturn(json_encode(['protected'=>'','payload'=>'','signature'=>''])); + + $order = json_decode($this->getOrderJSON($valid?'valid':'pending'), true); + $order['authorizations'] = []; + + $neworder=[]; + $neworder['header']='201 Created\r\nLocation: http://test.local/order/test'; + $neworder['status']=201; + $neworder['body']=$order; + + $connector->post('http://test.local/new-order', Argument::any()) + ->willReturn($neworder); + + $orderReq=[]; + $orderReq['header']='200 OK'; + $orderReq['status']=200; + $orderReq['body']=$order; + + $connector->get("http://test.local/order/test")->willReturn($orderReq); + + return $connector->reveal(); + } + + /** + * Covers the case where there are no authorizations in the order + */ + public function testAllAuthorizationsValid() + { + $conn = $this->mockConnectorWithNoAuths(); + $log = new NullLogger(); + $dns = $this->mockDNS(true); + $sleep = $this->mockSleep(); + $store = $this->initCertStore(); + $basename='example.org'; + $domains = ['example.org', 'test.example.org']; + $keyType = 'rsa'; + $notBefore = ''; + $notAfter = ''; + + $this->assertNull($store->getPublicKey($basename)); + + //this should create a new order + $order = new LEOrder($conn, $store, $log, $dns, $sleep); + $order->loadOrder($basename, $domains, $keyType, $notBefore, $notAfter); + + $this->assertFalse($order->allAuthorizationsValid()); + } + + /** + * @return LEConnector + */ + private function mockConnectorForProcessingCert($eventuallyValid = true, $goodCertRequest = true, $garbage = false) + { + $valid = true; + + $connector = $this->prophesize(LEConnector::class); + $connector->newOrder = 'http://test.local/new-order'; + $connector->revokeCert = 'http://test.local/revoke-cert'; + + $connector->signRequestKid(Argument::any(), Argument::any(), Argument::any()) + ->willReturn(json_encode(['protected'=>'','payload'=>'','signature'=>''])); + + $connector->signRequestJWK(Argument::any(), Argument::any(), Argument::any()) + ->willReturn(json_encode(['protected'=>'','payload'=>'','signature'=>''])); + + + //the new order is setup to be processing... + $neworder=[]; + $neworder['header']='201 Created\r\nLocation: http://test.local/order/test'; + $neworder['status']=201; + $neworder['body']=json_decode($this->getOrderJSON('valid'), true); + $neworder['body']['status'] = 'processing'; + + $connector->post('http://test.local/new-order', Argument::any()) + ->willReturn($neworder); + + $authz1=[]; + $authz1['header']='200 OK'; + $authz1['status']=200; + $authz1['body']=json_decode($this->getAuthzJSON('example.org', $valid), true); + $connector->get( + 'https://acme-staging-v02.api.letsencrypt.org/acme/authz/X2QaFXwrBz7VlN6zdKgm_jmiBctwVZgMZXks4YhfPng', + Argument::any() + )->willReturn($authz1); + + $authz2=[]; + $authz2['header']='200 OK'; + $authz2['status']=200; + $authz2['body']=json_decode($this->getAuthzJSON('test.example.org', $valid), true); + $connector->get( + 'https://acme-staging-v02.api.letsencrypt.org/acme/authz/WDMI8oX6avFT_rEBfh-ZBMdZs3S-7li2l5gRrps4MXM', + Argument::any() + )->willReturn($authz2); + + //when the order is re-fetched, it's possibly valid + $orderReq=[]; + $orderReq['header']='200 OK'; + $orderReq['status']=200; + $orderReq['body']=json_decode($this->getOrderJSON('valid'), true); + if (!$eventuallyValid) { + $orderReq['body']['status'] = 'processing'; + } + $connector->get('http://test.local/order/test')->willReturn($orderReq); + + $certReq=[]; + $certReq['header']=$goodCertRequest ? '200 OK' : '500 Failed'; + $certReq['status']=200; + $certReq['body']=$garbage ? 'NOT-A-CERT' : $this->getCertBody(); + $connector->get('https://acme-staging-v02.api.letsencrypt.org/acme/cert/fae09c6dcdaf7aa198092b3170c69129a490') + ->willReturn($certReq); + + $revokeReq=[]; + $revokeReq['header']='200 OK'; + $revokeReq['status']=200; + $revokeReq['body']=''; + $connector->post('http://test.local/revoke-cert', Argument::any()) + ->willReturn($revokeReq); + + $connector->post('http://test.local/bad-revoke-cert', Argument::any()) + ->willThrow(new RuntimeException('Revocation failed')); + + return $connector->reveal(); + } + + /** + * Test a certificate fetch with a 'processing' loop in effect + */ + public function testGetCertificate() + { + $conn = $this->mockConnectorForProcessingCert(true); + $log = new NullLogger(); + $dns = $this->mockDNS(true); + $sleep = $this->mockSleep(); + $store = $this->initCertStore(); + $basename='example.org'; + $domains = ['example.org', 'test.example.org']; + $keyType = 'rsa'; + $notBefore = ''; + $notAfter = ''; + + $this->assertNull($store->getPublicKey($basename)); + + //this should create a new order + $order = new LEOrder($conn, $store, $log, $dns, $sleep); + $order->loadOrder($basename, $domains, $keyType, $notBefore, $notAfter); + + $this->assertEmpty($store->getCertificate($basename)); + + $ok = $order->getCertificate(); + $this->assertTrue($ok); + $this->assertNotEmpty($store->getCertificate($basename)); + } + + /** + * Test a certificate fetch with a 'processing' loop in effect + */ + public function testGetCertificateWithValidationDelay() + { + $conn = $this->mockConnectorForProcessingCert(false); + $log = new NullLogger(); + $dns = $this->mockDNS(true); + $sleep = $this->mockSleep(); + $store = $this->initCertStore(); + $basename='example.org'; + $domains = ['example.org', 'test.example.org']; + $keyType = 'rsa'; + $notBefore = ''; + $notAfter = ''; + + $this->assertNull($store->getPublicKey($basename)); + + //this should create a new order + $order = new LEOrder($conn, $store, $log, $dns, $sleep); + $order->loadOrder($basename, $domains, $keyType, $notBefore, $notAfter); + + $ok = $order->getCertificate(); + $this->assertFalse($ok); + } + + public function testGetCertificateWithRetrievalFailure() + { + $conn = $this->mockConnectorForProcessingCert(true, false); + $log = new NullLogger(); + $dns = $this->mockDNS(true); + $sleep = $this->mockSleep(); + $store = $this->initCertStore(); + $basename='example.org'; + $domains = ['example.org', 'test.example.org']; + $keyType = 'rsa'; + $notBefore = ''; + $notAfter = ''; + + $this->assertNull($store->getPublicKey($basename)); + + //this should create a new order + $order = new LEOrder($conn, $store, $log, $dns, $sleep); + $order->loadOrder($basename, $domains, $keyType, $notBefore, $notAfter); + + $ok = $order->getCertificate(); + $this->assertFalse($ok); + } + + public function testGetCertificateWithGarbageRetrieval() + { + $conn = $this->mockConnectorForProcessingCert(true, true, true); + $log = new NullLogger(); + $dns = $this->mockDNS(true); + $sleep = $this->mockSleep(); + $store = $this->initCertStore(); + $basename='example.org'; + $domains = ['example.org', 'test.example.org']; + $keyType = 'rsa'; + $notBefore = ''; + $notAfter = ''; + + $this->assertNull($store->getPublicKey($basename)); + + //this should create a new order + $order = new LEOrder($conn, $store, $log, $dns, $sleep); + $order->loadOrder($basename, $domains, $keyType, $notBefore, $notAfter); + + $ok = $order->getCertificate(); + $this->assertFalse($ok); + } + + public function testRevoke() + { + $conn = $this->mockConnectorForProcessingCert(true); + $log = new NullLogger(); + $dns = $this->mockDNS(true); + $sleep = $this->mockSleep(); + $store = $this->initCertStore(); + $basename='example.org'; + $domains = ['example.org', 'test.example.org']; + $keyType = 'rsa'; + $notBefore = ''; + $notAfter = ''; + + $this->assertNull($store->getPublicKey($basename)); + + //this should create a new order + $order = new LEOrder($conn, $store, $log, $dns, $sleep); + $order->loadOrder($basename, $domains, $keyType, $notBefore, $notAfter); + $this->assertTrue($order->getCertificate()); + + $ok = $order->revokeCertificate(); + $this->assertTrue($ok); + } + + public function testRevokeIncompleteOrder() + { + $conn = $this->mockConnector(); + $log = new NullLogger(); + $dns = $this->mockDNS(true); + $sleep = $this->mockSleep(); + $store = $this->initCertStore(); + $basename='example.org'; + $domains = ['example.org', 'test.example.org']; + $keyType = 'rsa-4096'; + $notBefore = ''; + $notAfter = ''; + + $this->assertNull($store->getPublicKey($basename)); + + //this should create a new order + $order = new LEOrder($conn, $store, $log, $dns, $sleep); + $order->loadOrder($basename, $domains, $keyType, $notBefore, $notAfter); + + $this->assertNotEmpty($store->getPublicKey($basename)); + + //can't revoke + $ok = $order->revokeCertificate(); + $this->assertFalse($ok); + } + + public function testRevokeMissingCertificate() + { + $conn = $this->mockConnectorForProcessingCert(true); + $log = new NullLogger(); + $dns = $this->mockDNS(true); + $sleep = $this->mockSleep(); + $store = $this->initCertStore(); + $basename='example.org'; + $domains = ['example.org', 'test.example.org']; + $keyType = 'rsa'; + $notBefore = ''; + $notAfter = ''; + + $this->assertNull($store->getPublicKey($basename)); + + //this should create a new order + $order = new LEOrder($conn, $store, $log, $dns, $sleep); + $order->loadOrder($basename, $domains, $keyType, $notBefore, $notAfter); + $this->assertTrue($order->getCertificate()); + + //now we're going to remove the cert + $this->assertNotEmpty($store->getCertificate($basename)); + $store->setCertificate($basename, null); + + $ok = $order->revokeCertificate(); + $this->assertFalse($ok); + } + + /** + * @expectedException RuntimeException + */ + public function testRevokeFailure() + { + $conn = $this->mockConnectorForProcessingCert(true); + + //we use an alternate URL for revocation which fails with a 403 + $conn->revokeCert = 'http://test.local/bad-revoke-cert'; + + $log = new NullLogger(); + $dns = $this->mockDNS(true); + $sleep = $this->mockSleep(); + $store = $this->initCertStore(); + + $basename='example.org'; + $domains = ['example.org', 'test.example.org']; + $keyType = 'rsa'; + $notBefore = ''; + $notAfter = ''; + + $this->assertNull($store->getPublicKey($basename)); + + //this should create a new order + $order = new LEOrder($conn, $store, $log, $dns, $sleep); + $order->loadOrder($basename, $domains, $keyType, $notBefore, $notAfter); + $this->assertTrue($order->getCertificate()); + + //this should fail as we use a revocation url which simulates failure + $order->revokeCertificate(); + } +} diff --git a/tests/LETestCase.php b/tests/LETestCase.php new file mode 100644 index 0000000..2b02301 --- /dev/null +++ b/tests/LETestCase.php @@ -0,0 +1,629 @@ +prophesize(DNSValidatorInterface::class); + $dns->checkChallenge(Argument::any(), Argument::any()) + ->willReturn($success); + + return $dns->reveal(); + } + + /** + * @return Sleep + */ + protected function mockSleep() + { + //mock sleep service which, erm, won't sleep. Shave a few seconds off tests! + $sleep = $this->prophesize(Sleep::class); + $sleep->for(Argument::any())->willReturn(true); + return $sleep->reveal(); + } + + /** + * Recursive delete directory + * @param $dir + */ + protected function deleteDirectory($dir) + { + if (is_dir($dir)) { + $objects = scandir($dir); + foreach ($objects as $object) { + if ($object != "." && $object != "..") { + if (is_dir($dir . "/" . $object)) { + $this->deleteDirectory($dir . "/" . $object); + } else { + unlink($dir . "/" . $object); + } + } + } + rmdir($dir); + } + } + + /** + * Simulated response to GET https://acme-staging-v02.api.letsencrypt.org/directory + */ + protected function getDirectoryResponse() + { + $body = <<format('D, j M Y H:i:s e'); + + + $headers = [ + 'Server' => 'nginx', + 'Content-Type' => 'application/json', + 'Content-Length' => strlen($body), + 'X-Frame-Options' => 'DENY', + 'Strict-Transport-Security' => 'max-age=604800', + 'Expires' => $nowFmt, + 'Cache-Control' => 'max-age=0, no-cache, no-store', + 'Pragma' => 'no-cache', + 'Date' => $nowFmt, + 'Connection' => 'keep-alive' + ]; + + return new Response(200, $headers, $body); + } + + /** + * Simulated response for HEAD https://acme-staging-v02.api.letsencrypt.org/acme/new-nonce + * @return Response + */ + protected function headNewNonceResponse() + { + $now = new \DateTime; + $nowFmt = $now->format('D, j M Y H:i:s e'); + + $headers = [ + 'Server' => 'nginx', + 'Replay-Nonce' => 'nBmz5qIrxfRE12DYK0ZN2PvS-3PlPy0OWBPHljRvjlg', + 'X-Frame-Options' => 'DENY', + 'Strict-Transport-Security' => 'max-age=604800', + 'Expires' => $nowFmt, + 'Cache-Control' => 'max-age=0, no-cache, no-store', + 'Pragma' => 'no-cache', + 'Date' => $nowFmt, + 'Connection' => 'keep-alive' + ]; + + return new Response(200, $headers); + } + + /** + * Simulated response for a 404 which includes a JSON error + * @return Response + */ + protected function getMissingResponse() + { + $now = new \DateTime; + $nowFmt = $now->format('D, j M Y H:i:s e'); + + $body=['detail' => 'Requested object not found']; + $json = json_encode($body); + + $headers = [ + 'Server' => 'nginx', + 'X-Frame-Options' => 'DENY', + 'Strict-Transport-Security' => 'max-age=604800', + 'Expires' => $nowFmt, + 'Cache-Control' => 'max-age=0, no-cache, no-store', + 'Pragma' => 'no-cache', + 'Date' => $nowFmt, + 'Connection' => 'keep-alive', + 'Content-Type' => 'application/json', + 'Content-Length' => strlen($json), + ]; + + return new Response(404, $headers, $json); + } + + protected function postNewAccountJSON() + { + $date = new \DateTime; + $isoNow = $date->format('c'); + + $n='35wpDxjGtu4o6AZVA1l4qaDhVUtpkW-iFSHXWzMJMyjVLj9kVN8ZMky6y47VwctZhX0WdL7PLKfJslVUnQkP0kXD_AIPHdMjgOHqlNR_'. + '4gNFIc8vpT8qjzfVzv5GMnDhTmzAH_YtemSkVJ3NwJxzcn5sjGsaQaHOIZMWbHnEq9LYHrBPzjITG_PLEGsmfjt5cYdzajif7RLYm_C'. + 'luGqZBOxhyy5_Q80m5lVg7tefaGsNK4rzZi2vWd1SIt_3vTBPc1YO9PtNoE-r6MpWUmRxQThcFivYT1iDNNY5oUtJDV8RFQ484P5C43'. + 'Ovj8HagiuZAIyQ6qKXly3o7ShFmY6VqXnHakPKJpk9MFR26qXiSkBWklDV5OEaslPXRetinhbcwNNYibrp7oJcPuTYLQz5DYvmcIGuS'. + 'Pxo1WmjkKPXRmgYkk76QBuYabEgs94jxUgz8Ez5YdqydFfnBGmQfgI_mzxlsZxwv1ArxlWsLP5tkRkBevXM4foY7Crek8_8YaW_4Jvz'. + 'KFF9dQctBmjFwNKjNcuJeKBM6wjQ6tIE13Lz8TTV8KaYbwEBFWjnXUKCSJAajFTSTDmo08kqdgQ2Awzku_JFWzkf-tuSQPmIc0kObRI'. + 'yFz6FDNX0j4Qpk_-V_Fu8QhAE5u9rwjMuhd8ypoNp-LdewNA4osCxSg0usM7p-n8'; + + $body = <<format('D, j M Y H:i:s e'); + + $body = $this->postNewAccountJSON(); + + $headers = [ + 'Server' => 'nginx', + 'Content-Type' => 'application/json', + 'Content-Length' => strlen($body), + 'Boulder-Requester' => '5757881', + 'Link' => ';rel="terms-of-service"', + 'Location' => 'https://acme-staging-v02.api.letsencrypt.org/acme/acct/5757881', + 'Replay-Nonce' => 'RDDmW0V4WI-6tONjK3XCTY6u8Bvax6IvxKXG9jvqBig', + 'X-Frame-Options' => 'DENY', + 'Strict-Transport-Security' => 'max-age=604800', + 'Expires' => $now, + 'Cache-Control' => 'max-age=0, no-cache, no-store', + 'Pragma' => 'no-cache', + 'Date' => $now, + 'Connection' => 'keep-alive', + ]; + return new Response(201, $headers, $body); + } + + /** + * Simulate response for POST https://acme-staging-v02.api.letsencrypt.org/acme/acct/5757881 + */ + protected function postAccountResponse() + { + $date = new \DateTime; + $now = $date->format('D, j M Y H:i:s e'); + $isoNow = $date->format('c'); + + $n='35wpDxjGtu4o6AZVA1l4qaDhVUtpkW-iFSHXWzMJMyjVLj9kVN8ZMky6y47VwctZhX0WdL7PLKfJslVUnQkP0kXD_AIPHdMjgOHqlNR_'. + '4gNFIc8vpT8qjzfVzv5GMnDhTmzAH_YtemSkVJ3NwJxzcn5sjGsaQaHOIZMWbHnEq9LYHrBPzjITG_PLEGsmfjt5cYdzajif7RLYm_C'. + 'luGqZBOxhyy5_Q80m5lVg7tefaGsNK4rzZi2vWd1SIt_3vTBPc1YO9PtNoE-r6MpWUmRxQThcFivYT1iDNNY5oUtJDV8RFQ484P5C43'. + 'Ovj8HagiuZAIyQ6qKXly3o7ShFmY6VqXnHakPKJpk9MFR26qXiSkBWklDV5OEaslPXRetinhbcwNNYibrp7oJcPuTYLQz5DYvmcIGuS'. + 'Pxo1WmjkKPXRmgYkk76QBuYabEgs94jxUgz8Ez5YdqydFfnBGmQfgI_mzxlsZxwv1ArxlWsLP5tkRkBevXM4foY7Crek8_8YaW_4Jvz'. + 'KFF9dQctBmjFwNKjNcuJeKBM6wjQ6tIE13Lz8TTV8KaYbwEBFWjnXUKCSJAajFTSTDmo08kqdgQ2Awzku_JFWzkf-tuSQPmIc0kObRI'. + 'yFz6FDNX0j4Qpk_-V_Fu8QhAE5u9rwjMuhd8ypoNp-LdewNA4osCxSg0usM7p-n8'; + + + + $body = << 'nginx', + 'Content-Type' => 'application/json', + 'Content-Length' => strlen($body), + 'Boulder-Requester' => '5757881', + 'Link' => ';rel="terms-of-service"', + 'Replay-Nonce' => 'Wa57-T-1ogpJPKmee3VE6OsUJQ97d-zn5_OSWnt4CbA', + 'X-Frame-Options' => 'DENY', + 'Strict-Transport-Security' => 'max-age=604800', + 'Expires' => $now, + 'Cache-Control' => 'max-age=0, no-cache, no-store', + 'Pragma' => 'no-cache', + 'Date' => $now, + 'Connection' => 'keep-alive', + ]; + return new Response(200, $headers, $body); + } + + protected function getOrderJSON($orderStatus = 'pending') + { + $expires = new \DateTime; + $expires->add(new \DateInterval('P7D')); + $isoExpires = $expires->format('c'); + + $cert=''; + if ($orderStatus === 'valid') { + $cert = '"certificate": ' . + '"https://acme-staging-v02.api.letsencrypt.org/acme/cert/fae09c6dcdaf7aa198092b3170c69129a490",'; + } + + $json = <<format('D, j M Y H:i:s e'); + + $body = $this->getOrderJSON(); + + $headers = [ + 'Server' => 'nginx', + 'Content-Type' => 'application/json', + 'Content-Length' => strlen($body), + 'Boulder-Requester' => '5758369', + 'Location' => 'https://acme-staging-v02.api.letsencrypt.org/acme/order/5758369/94473', + 'Replay-Nonce' => 'rWPDZxnr7VhwT6suSqNjaZhHsTWAPHwihf32CAGVXqc', + 'X-Frame-Options' => 'DENY', + 'Strict-Transport-Security' => 'max-age=604800', + 'Expires' => $now, + 'Cache-Control' => 'max-age=0, no-cache, no-store', + 'Pragma' => 'no-cache', + 'Date' => $now, + 'Connection' => 'keep-alive', + ]; + return new Response(201, $headers, $body); + } + + protected function getAuthzJSON($domain = 'test.example.org', $dnsValidated = false) + { + $expires = new \DateTime; + $expires->add(new \DateInterval('P7D')); + $isoExpires = $expires->format('c'); + + $status = $dnsValidated ? 'valid' : 'pending'; + + $validationRecord=''; + if ($dnsValidated) { + $validationRecord=<<format('D, j M Y H:i:s e'); + + $body=$this->getAuthzJSON($domain, $dnsValidated); + + $headers = [ + 'Server' => 'nginx', + 'Content-Type' => 'application/json', + 'Content-Length' => strlen($body), + 'X-Frame-Options' => 'DENY', + 'Strict-Transport-Security' => 'max-age=604800', + 'Expires' => $now, + 'Cache-Control' => 'max-age=0, no-cache, no-store', + 'Pragma' => 'no-cache', + 'Date' => $now, + 'Connection' => 'keep-alive', + ]; + return new Response(200, $headers, $body); + } + + + /** + * Simulate response for POST https://acme-staging-v02.api.letsencrypt.org/acme/challenge/.../... + */ + protected function postChallengeResponse() + { + $prefix='https://acme-staging-v02.api.letsencrypt.org/acme'; + + $date = new \DateTime; + $now = $date->format('D, j M Y H:i:s e'); + $body = << 'nginx', + 'Content-Type' => 'application/json', + 'Content-Length' => strlen($body), + 'Boulder-Requester' => '5758369', + 'Link' => "<$prefix/authz/rApg01jrldnZ648uZIorI1JtQLuz9nHu2mjZt_NS2WU>;rel=\"up\"", + 'Location' => "$prefix/challenge/rApg01jrldnZ648uZIorI1JtQLuz9nHu2mjZt_NS2WU/110041513", + 'Replay-Nonce' => '0NJ_rSgGswOF8jSsT4aTtZj2QA0NMaVmtCDwMIUHHrw', + 'X-Frame-Options' => 'DENY', + 'Strict-Transport-Security' => 'max-age=604800', + 'Expires' => $now, + 'Cache-Control' => 'max-age=0, no-cache, no-store', + 'Pragma' => 'no-cache', + 'Date' => $now, + 'Connection' => 'keep-alive', + ]; + return new Response(200, $headers, $body); + } + + + /** + * Simulate response for POST https://acme-staging-v02.api.letsencrypt.org/acme/finalize/5758753/94699 + */ + protected function getPostFinalizeResponse() + { + $date = new \DateTime; + $now = $date->format('D, j M Y H:i:s e'); + + $expires = new \DateTime; + $expires->add(new \DateInterval('P7D')); + $isoExpires = $expires->format('c'); + + $body = << 'nginx', + 'Content-Type' => 'application/json', + 'Content-Length' => strlen($body), + 'Boulder-Requester' => '5758369', + 'Location' => 'https://acme-staging-v02.api.letsencrypt.org/acme/order/5758369/94699', + 'Replay-Nonce' => 'QFA6urc60RnOmGmM0ni5VYJsB0_VwPmY-4vo18OlL8o', + 'X-Frame-Options' => 'DENY', + 'Strict-Transport-Security' => 'max-age=604800', + 'Expires' => $now, + 'Cache-Control' => 'max-age=0, no-cache, no-store', + 'Pragma' => 'no-cache', + 'Date' => $now, + 'Connection' => 'keep-alive', + ]; + return new Response(200, $headers, $body); + } + + /** + * Note that certificate below is deliberate garbage - for testing, we don't need a real cert + * @return string + */ + protected function getCertBody() + { + $body = <<format('D, j M Y H:i:s e'); + $body = $this->getCertBody(); + + $headers=[ + 'Server' => 'nginx', + 'Content-Type' => 'application/pem-certificate-chain', + 'Content-Length' => strlen($body), + 'X-Frame-Options' => 'DENY', + 'Strict-Transport-Security' => 'max-age=604800', + 'Expires' => $now, + 'Cache-Control' => 'max-age=0, no-cache, no-store', + 'Pragma' => 'no-cache', + 'Date' => $now, + 'Connection' => 'keep-alive', + ]; + return new Response(200, $headers, $body); + } + + /** + * Simulate response for POST https://acme-staging-v02.api.letsencrypt.org/acme/revoke-cert + */ + protected function getRevokeCertResponse() + { + $date = new \DateTime; + $now = $date->format('D, j M Y H:i:s e'); + + $headers=[ + 'Server' => 'nginx', + 'Replay-Nonce' => 'z4yMN4_LKg22VZzJZkUe5YuSiC0sknSuwIfC2GF-FOw', + 'X-Frame-Options' => 'DENY', + 'Strict-Transport-Security' => 'max-age=604800', + 'Expires' => $now, + 'Cache-Control' => 'max-age=0, no-cache, no-store', + 'Pragma' => 'no-cache', + 'Date' => $now, + 'Connection' => 'keep-alive', + ]; + return new Response(200, $headers); + } +}