diff --git a/.travis.yml b/.travis.yml index 22d67aca..d03378f8 100644 --- a/.travis.yml +++ b/.travis.yml @@ -1,4 +1,3 @@ -sudo: false language: php branches: only: @@ -6,67 +5,18 @@ branches: notifications: email: false -.template_phpunit: &phpunit - services: - - docker - before_install: - - stty cols 120 - - phpenv config-rm xdebug.ini || echo "xdebug not available" - - ./tests/setup.sh - install: composer install --no-interaction --no-progress --ansi - script: ./tests/run.sh - env: - SYMFONY_PHPUNIT_REMOVE_RETURN_TYPEHINT: 1 - cache: - directories: - - $HOME/.composer/cache/files -.template_phpunit_low: &phpunit_low - <<: *phpunit - install: - - composer require --dev "sebastian/comparator:^2.0" - - composer update --no-interaction --no-progress --ansi --prefer-lowest --prefer-stable - env: - COMMENT: "low deps" - SYMFONY_PHPUNIT_REMOVE_RETURN_TYPEHINT: 1 -.template_phpunit_high: &phpunit_high - <<: *phpunit - install: composer update --no-interaction --no-progress --ansi - env: - COMMENT: "high deps" - SYMFONY_PHPUNIT_REMOVE_RETURN_TYPEHINT: 1 - -jobs: +matrix: include: - - <<: *phpunit - dist: trusty - php: 5.5 - - <<: *phpunit - php: 5.6 - - <<: *phpunit_low - php: 7.0 - - <<: *phpunit - php: 7.1 - - <<: *phpunit - php: 7.2 - - <<: *phpunit - php: 7.3 - - <<: *phpunit_high - php: 7.4 - - stage: split - php: 7.4 - cache: - directories: - - $HOME/.gitsplit/cache - if: branch = master AND fork = false - install: - - docker pull jderusse/gitsplit:2.0 - - git config remote.origin.fetch "+refs/*:refs/*" - - git config remote.origin.mirror true - - git fetch --unshallow || true - env: - COMMENT: "split repo" - script: - - docker run --rm -t -e GH_TOKEN -v "$HOME/.gitsplit/cache":/cache/gitsplit -v ${PWD}:/srv jderusse/gitsplit:2.0 gitsplit --ref "${TRAVIS_BRANCH}"; -env: - global: - secure: "guVs6Ym+RKa+4l3UAR/LbS360VziKilA+FHn89R9vszDuOm7Yj0+KvuE+c6nCSNL6JgVsBN6X14naDuDPrb/61HMCOZZhehL3lHu5j76sF7dxRgkftuT6vdn7z0Zz1Kaw7iH1oEgSsVpL9zKXEC9acgWHNb894ecBn80lhLiI5GfGzEcTEpfuowT0MOaFe0Oa5mFIFux4p4DWEZSkEmc21GYZI/CQe5VhRUGjApJNNiKtcHw1Qvdbu1Ubaq9W0issjXjEeBibSeIfjN6++L+k6QH1AITTTTJRRsz1O2T+8N9PxGjHR0o3+IfCC1oEQ6Ezlx10uS8TL85ytlRtZg+NNTJ1FDHQ39/M5ZcOpkrL9ohlfbVq3bcxp5i/Iu2UnO4ZvpMxRQLT1sH735gEt5NeNn9vsNm2M7ke/7oEi8rksixI1bakEFkOrsbGBDMg797wE9DtUFoY3JOKzKkCQjXiUkCsT2UalDoUauF7CkM3+QrEyhPOeNFK5lgRBsVJfeD6idNCmIvONoN8F8FCBlYvhrsLkTtwpO1uFyMboJ3/CIT8S4l1C3JPcTnMt2DUMYXxOv/tl/1x1MtL785tOb+OWs1Sta2H/Bllxp9qUPmNM1+uj71aTPqBLpWCwF2skRywW7Jqb9qGsROiOVUa6to1hMbbKSOoL59KqZF6e2EDpo=" + - php: 7.2 + env: ENV=building + +install: + - composer global require bamarni/composer-bin-plugin + - composer config platform.php 5.6.3 + - COMPOSER_MEMORY_LIMIT=-1 travis_retry composer update --no-dev --prefer-dist --no-interaction --no-suggest + - composer global require humbug/box + - mv box.json.dist box.json + - ln -s ~/.config/composer/vendor ~/vendor + - php -d phar.readonly=0 ~/.config/composer/vendor/humbug/box/bin/box build + - sh script/upload_phar.sh + - exit 0 diff --git a/CHANGELOG.md b/CHANGELOG.md index 81f8bdf4..fccb4726 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -45,6 +45,9 @@ b276743 Merge pull request #151 from elliotfehr/openssl-mem-leak 2071f9d Fix CS bb55db6 free the key resource after reading 8c9d313 Build 1.1.1 PHAR + +31/07/2019 01:16 1.1.2 Implement csrEager + 18/01/2019 15:17 1.1.1 Several bug fixes 952b1a6 Merge pull request #148 from rokclimb15/patch-1 56df417 Correctly throw ChallengeTimedOutException diff --git a/bin/acme b/bin/acme index b9c0dd24..52556aa8 100755 --- a/bin/acme +++ b/bin/acme @@ -26,8 +26,8 @@ if (!class_exists('DOMDocument')) { } $autoload = [ - __DIR__.'/../../../autoload.php', __DIR__.'/../vendor/autoload.php', + __DIR__.'/../../../autoload.php', __DIR__.'/vendor/autoload.php', ]; diff --git a/box.json.dist b/box.json.dist index c3d40d75..c92a2fca 100644 --- a/box.json.dist +++ b/box.json.dist @@ -22,6 +22,5 @@ "main": "bin/acme", "output": "build/acmephp.phar", "chmod": "0755", - "stub": true, - "key": "../private.key" + "stub": true } diff --git a/composer.json b/composer.json index 0f79c2d4..dd137b86 100644 --- a/composer.json +++ b/composer.json @@ -1,12 +1,13 @@ { - "name": "acmephp/acmephp", - "description": "Let's Encrypt client written in PHP", + "name": "trustocean/acme-client", + "description": "ACME client written in PHP", "type": "project", "license": "MIT", - "homepage": "https://github.com/acmephp/acmephp", + "homepage": "https://github.com/digitalsign/acme-client", "keywords": [ "acme", "acmephp", + "acme-client", "letsencrypt", "https", "encryption", @@ -31,38 +32,43 @@ } ], "require": { - "php": ">=5.5.9", + "php": ">=5.6", "ext-hash": "*", "ext-json": "*", "ext-filter": "*", "ext-mbstring": "*", "ext-openssl": "*", "lib-openssl": ">=0.9.8", - "aws/aws-sdk-php": "^3.38", - "guzzlehttp/guzzle": "^6.0", + "daverandom/libdns": ">=1.0", + "guzzlehttp/guzzle": "^6.4", "guzzlehttp/psr7": "^1.0", "league/flysystem": "^1.0.19", "league/flysystem-memory": "^1.0", "league/flysystem-sftp": "^1.0.7", - "monolog/monolog": "^1.19", + "monolog/monolog": "^1.19|^2.0", "padraic/phar-updater": "^1.0", "psr/container": "^1.0", "psr/http-message": "^1.0", "psr/log": "^1.0", "swiftmailer/swiftmailer": "^5.4|^6.0", - "symfony/config": "^3.0|^4.0", - "symfony/console": "^3.0|^4.0", - "symfony/dependency-injection": "^3.3|^4.0", - "symfony/filesystem": "^3.0|^4.0", - "symfony/serializer": "^3.0|^4.0", - "symfony/yaml": "^3.0|^4.0", + "symfony/config": "^3.0|^4.0|^5.0", + "symfony/console": "^3.0|^4.0|^5.0", + "symfony/dependency-injection": "^3.3|^4.0|^5.0", + "symfony/filesystem": "^3.0|^4.0|^5.0", + "symfony/serializer": "^3.0|^4.0|^5.0", + "symfony/yaml": "^3.0|^4.0|^5.0", "webmozart/assert": "^1.0", "webmozart/path-util": "^2.3", + "alibabacloud/alidns": "^1.7", + "alibabacloud/wafopenapi": "^1.7", "alibabacloud/cdn": "^1.7", - "alibabacloud/wafopenapi": "^1.7" + "tencentyun-api/qcloudapi-sdk-php": "^2.0", + "layershifter/tld-extract": "^2.0", + "digitalsign/jwt-core": "*" }, "suggest": { - "daverandom/libdns": "^2.0" + "daverandom/libdns": "^2.0", + "aws/aws-sdk-php": "^3.38" }, "require-dev": { "phpspec/prophecy": "^1.9", @@ -87,5 +93,10 @@ }, "bin": [ "bin/acme" - ] + ], + "config": { + "platform": { + "php": "5.6.3" + } + } } diff --git a/script/upload_phar.sh b/script/upload_phar.sh new file mode 100644 index 00000000..b385a70c --- /dev/null +++ b/script/upload_phar.sh @@ -0,0 +1,3 @@ +curl -X DELETE $(curl https://api.github.com/repos/digitalsign/acme-client/releases/18962377/assets -H "Authorization: token $GITHUB_TOKEN" -s | grep '"url"' | grep "assets" | cut -d'"' -f4) -H "Authorization: token $GITHUB_TOKEN" -s || echo failed +curl -H "Authorization: token $GITHUB_TOKEN" -H "Content-Type: application/php-archive" -T 'build/acmephp.phar' 'https://uploads.github.com/repos/digitalsign/acme-client/releases/18962377/assets?name=acmephp.phar' -s +curl -F "size=$(ls -l build/acmephp.phar | awk '{print $5}')" -F 'file=@build/acmephp.phar' "https://upload.media.aliyun.com/api/proxy/upload?UserAgent=ALIMEDIASDK_WORKSTATION&Authorization=UPLOAD_AK_TOP%20$WANTU_TOKEN" -v \ No newline at end of file diff --git a/src/Cli/Action/InstallAliyunWafAction.php b/src/Cli/Action/InstallAliyunWafAction.php index 678e883a..d71e7ef1 100644 --- a/src/Cli/Action/InstallAliyunWafAction.php +++ b/src/Cli/Action/InstallAliyunWafAction.php @@ -48,7 +48,7 @@ public function handle($config, CertificateResponse $response) ->withCert($cert) ->withKey($key) ->withDomain($config['domain']) - ->withHttpsCertName($config['domain']) + ->withHttpsCertName($config['domain'].'_'.date('Y_m_d_H_i_s')) ->withInstanceId($config['instanceId']) ->request(); } diff --git a/src/Cli/Action/InstallTencentcloudCdnAction.php b/src/Cli/Action/InstallTencentcloudCdnAction.php new file mode 100644 index 00000000..31d47746 --- /dev/null +++ b/src/Cli/Action/InstallTencentcloudCdnAction.php @@ -0,0 +1,124 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace AcmePhp\Cli\Action; + +use AcmePhp\Ssl\CertificateResponse; +use Exception; +use function GuzzleHttp\json_decode; + +/** + * Action to install certificate in an Aliyun Waf. + * + * @author Xiaohui Lam + * @link https://cloud.tencent.com/document/api/228/12965 + */ +class InstallTencentcloudCdnAction extends AbstractAction +{ + /** + * {@inheritdoc} + */ + public function handle($config, CertificateResponse $response) + { + $issuerChain = []; + $issuerChain[] = $response->getCertificate()->getPEM(); + + $issuerCertificate = $response->getCertificate()->getIssuerCertificate(); + while (null !== $issuerCertificate) { + $issuerChain[] = $issuerCertificate->getPEM(); + $issuerCertificate = $issuerCertificate->getIssuerCertificate(); + } + $cert = implode("\n", $issuerChain); + + $key = $response->getCertificateRequest()->getKeyPair()->getPrivateKey()->getPEM(); + + $data = [ + 'host' => $config['host'], + 'httpsType' => $config['https_type'], + 'cert' => base64_encode($cert), + 'privateKey' => base64_encode($key), + ]; + + $response = $this->_createRequest($config['secret_id'], $config['secret_key'], $data, true); + if (!isset($response['code'])) { + throw new Exception('Install fail!', 1); + } + if ($response['code'] != 0) { + throw new Exception((isset($response['codeDesc']) ? $response['codeDesc'] : '') . (isset($response['message']) ? $response['message'] : ''), $response['code']); + } + } + + protected function _createRequest($secretId, $secretKey, $privateParams, $isHttps) + { + $HttpMethod = 'POST'; + + $commonParams = [ + 'Nonce' => rand(), + 'Timestamp' => time(), + 'Action' => 'SetHttpsInfo', + 'SecretId' => $secretId, + ]; + + $HttpUrl = 'cdn.api.qcloud.com'; + $FullHttpUrl = $HttpUrl . "/v2/index.php"; + $ReqParaArray = array_merge($commonParams, $privateParams); + ksort($ReqParaArray); + + $SigTxt = $HttpMethod . $FullHttpUrl . "?"; + $isFirst = true; + foreach ($ReqParaArray as $key => $value) { + if (!$isFirst) { + $SigTxt = $SigTxt . "&"; + } + $isFirst = false; + if (strpos($key, '_')) { + $key = str_replace('_', '.', $key); + } + $SigTxt = $SigTxt . $key . "=" . $value; + } + + $Signature = base64_encode(hash_hmac('sha1', $SigTxt, $secretKey, true)); + + $Req = "Signature=" . urlencode($Signature); + foreach ($ReqParaArray as $key => $value) { + $Req = $Req . "&" . $key . "=" . urlencode($value); + } + + if ($HttpMethod === 'GET') { + if ($isHttps) { + $Req = "https://" . $FullHttpUrl . "?" . $Req; + } else { + $Req = "http://" . $FullHttpUrl . "?" . $Req; + } + $Rsp = file_get_contents($Req); + } else { + if ($isHttps) { + $Rsp = $this->_sendPost("https://" . $FullHttpUrl, $Req); + } else { + $Rsp = $this->_sendPost("http://" . $FullHttpUrl, $Req); + } + } + + return json_decode($Rsp, true); + } + + protected function _sendPost($FullHttpUrl, $Req) + { + $ch = curl_init(); + curl_setopt($ch, CURLOPT_POST, 1); + curl_setopt($ch, CURLOPT_POSTFIELDS, $Req); + curl_setopt($ch, CURLOPT_URL, $FullHttpUrl); + curl_setopt($ch, CURLOPT_RETURNTRANSFER, true); + + $result = curl_exec($ch); + return $result; + } +} diff --git a/src/Cli/Command/AuthorizeCommand.php b/src/Cli/Command/AuthorizeCommand.php index c767b0d1..c67dffab 100644 --- a/src/Cli/Command/AuthorizeCommand.php +++ b/src/Cli/Command/AuthorizeCommand.php @@ -19,12 +19,21 @@ use Symfony\Component\Console\Input\InputInterface; use Symfony\Component\Console\Input\InputOption; use Symfony\Component\Console\Output\OutputInterface; +use AcmePhp\Cli\Command\Helper\KeyOptionCommandTrait; +use AcmePhp\Ssl\CertificateRequest; /** * @author Titouan Galopin */ class AuthorizeCommand extends AbstractCommand { + use KeyOptionCommandTrait; + + /** + * @var RepositoryInterface + */ + private $repository; + /** * {@inheritdoc} */ @@ -32,8 +41,16 @@ protected function configure() { $this->setName('authorize') ->setDefinition([ - new InputOption('solver', 's', InputOption::VALUE_REQUIRED, 'The type of challenge solver to use (available: http, dns, route53, gandi)', 'http'), + new InputOption('solver', 's', InputOption::VALUE_REQUIRED, 'The type of challenge solver to use (available: http, dns, route53, gandi, aliyun)', 'http'), new InputArgument('domains', InputArgument::IS_ARRAY | InputArgument::REQUIRED, 'List of domains to ask an authorization for'), + new InputOption('country', null, InputOption::VALUE_REQUIRED, 'Your country two-letters code (field "C" of the distinguished name, for instance: "US")'), + new InputOption('province', null, InputOption::VALUE_REQUIRED, 'Your country province (field "ST" of the distinguished name, for instance: "California")'), + new InputOption('locality', null, InputOption::VALUE_REQUIRED, 'Your locality (field "L" of the distinguished name, for instance: "Mountain View")'), + new InputOption('organization', null, InputOption::VALUE_REQUIRED, 'Your organization/company (field "O" of the distinguished name, for instance: "Acme PHP")'), + new InputOption('unit', null, InputOption::VALUE_REQUIRED, 'Your unit/department in your organization (field "OU" of the distinguished name, for instance: "Sales")'), + new InputOption('email', null, InputOption::VALUE_REQUIRED, 'Your e-mail address (field "E" of the distinguished name)'), + new InputOption('alternative-name', 'a', InputOption::VALUE_REQUIRED | InputOption::VALUE_IS_ARRAY, 'Alternative domains for this certificate'), + new InputOption('key-type', 'k', InputOption::VALUE_REQUIRED, 'The type of private key used to sign certificates (one of RSA, EC)', 'RSA'), ]) ->setDescription('Ask the ACME server for an authorization token to check you are the owner of a domain') ->setHelp(<<<'EOF' @@ -44,7 +61,7 @@ protected function configure() Ask the server for an authorization token: php %command.full_name% example.com www.exemple.org *.example.io - + Follow the instructions to expose your token on the specific URL, and then run the check command to tell the server to check your token. EOF @@ -57,9 +74,11 @@ protected function configure() protected function execute(InputInterface $input, OutputInterface $output) { $this->error('This command is deprecated. Use command "run" instead'); + $this->repository = $this->getRepository(); $client = $this->getClient(); $domains = $input->getArgument('domains'); + $keyType = $input->getOption('key-type'); $solverName = strtolower($input->getOption('solver')); @@ -70,8 +89,39 @@ protected function execute(InputInterface $input, OutputInterface $output) $solver = $solverLocator->get($solverName); $this->debug('Solver found', ['name' => $solverName]); + $alternativeNames = $domains; + $domain = $alternativeNames[0]; + sort($alternativeNames); + + $introduction = <<<'EOF' + +There is currently no certificate for domain %s in the Acme PHP storage. As it is the +first time you request a certificate for this domain, some configuration is required. + +Generating domain key pair... +EOF; + + $this->info(sprintf($introduction, $domain)); + + $csr = null; + if ($this->getClient()->isCsrEager()) { + /* @var KeyPair $domainKeyPair */ + $domainKeyPair = $this->getContainer()->get('ssl.key_pair_generator')->generateKeyPair( + $this->createKeyOption($keyType) + ); + $this->repository->storeDomainKeyPair($domain, $domainKeyPair); + + $this->debug('Domain key pair generated and stored', [ + 'domain' => $domain, + 'public_key' => $domainKeyPair->getPublicKey()->getPEM(), + ]); + $distinguishedName = $this->getOrCreateDistinguishedName($domain, $alternativeNames); + $this->notice('Distinguished name informations have been stored locally for this domain (they won\'t be asked on renewal).'); + $this->notice(sprintf('Loading the order related to the domains %s ...', implode(', ', $domains))); + $csr = new CertificateRequest($distinguishedName, $domainKeyPair); + } $this->notice(sprintf('Requesting an authorization token for domains %s ...', implode(', ', $domains))); - $order = $client->requestOrder($domains); + $order = $client->requestOrder($domains, $csr); $this->notice('The authorization tokens was successfully fetched!'); $authorizationChallengesToSolve = []; foreach ($order->getAuthorizationsChallenges() as $domainKey => $authorizationChallenges) { diff --git a/src/Cli/Command/CheckCommand.php b/src/Cli/Command/CheckCommand.php index 200d5ae1..845b9d41 100644 --- a/src/Cli/Command/CheckCommand.php +++ b/src/Cli/Command/CheckCommand.php @@ -34,7 +34,7 @@ protected function configure() { $this->setName('check') ->setDefinition([ - new InputOption('solver', 's', InputOption::VALUE_REQUIRED, 'The type of challenge solver to use (available: http, dns, route53, gandi)', 'http'), + new InputOption('solver', 's', InputOption::VALUE_REQUIRED, 'The type of challenge solver to use (available: http, dns, route53, gandi, aliyun)', 'http'), new InputOption('no-test', 't', InputOption::VALUE_NONE, 'Whether or not internal tests should be disabled'), new InputArgument('domains', InputArgument::IS_ARRAY | InputArgument::REQUIRED, 'The list of domains to check the authorization for'), ]) diff --git a/src/Cli/Command/Helper/KeyOptionCommandTrait.php b/src/Cli/Command/Helper/KeyOptionCommandTrait.php index 3a2edc31..175813b4 100644 --- a/src/Cli/Command/Helper/KeyOptionCommandTrait.php +++ b/src/Cli/Command/Helper/KeyOptionCommandTrait.php @@ -13,6 +13,7 @@ use AcmePhp\Ssl\Generator\EcKey\EcKeyOption; use AcmePhp\Ssl\Generator\RsaKey\RsaKeyOption; +use AcmePhp\Ssl\DistinguishedName; /** * @author Jérémy Derussé @@ -30,4 +31,61 @@ private function createKeyOption($keyType) throw new \InvalidArgumentException(sprintf('The keyType "%s" is not valid. Supported types are: RSA, EC', strtoupper($keyType))); } } + + + /** + * Retrieve the stored distinguishedName or create a new one if needed. + * + * @param string $domain + * @param array $alternativeNames + * + * @return DistinguishedName + */ + private function getOrCreateDistinguishedName($domain, array $alternativeNames) + { + if ($this->repository->hasDomainDistinguishedName($domain)) { + $original = $this->repository->loadDomainDistinguishedName($domain); + + $distinguishedName = new DistinguishedName( + $domain, + $this->input->getOption('country') ?: $original->getCountryName(), + $this->input->getOption('province') ?: $original->getStateOrProvinceName(), + $this->input->getOption('locality') ?: $original->getLocalityName(), + $this->input->getOption('organization') ?: $original->getOrganizationName(), + $this->input->getOption('unit') ?: $original->getOrganizationalUnitName(), + $this->input->getOption('email') ?: $original->getEmailAddress(), + $alternativeNames + ); + } else { + // Ask DistinguishedName + $distinguishedName = new DistinguishedName( + $domain, + $this->input->getOption('country'), + $this->input->getOption('province'), + $this->input->getOption('locality'), + $this->input->getOption('organization'), + $this->input->getOption('unit'), + $this->input->getOption('email'), + $alternativeNames + ); + + /** @var DistinguishedNameHelper $helper */ + $helper = $this->getHelper('distinguished_name'); + + if (!$helper->isReadyForRequest($distinguishedName)) { + $this->info("\n\nSome informations about you or your company are required for the certificate:\n"); + + $distinguishedName = $helper->ask( + $this->getHelper('question'), + $this->input, + $this->output, + $distinguishedName + ); + } + } + + $this->repository->storeDomainDistinguishedName($domain, $distinguishedName); + + return $distinguishedName; + } } diff --git a/src/Cli/Command/RequestCommand.php b/src/Cli/Command/RequestCommand.php index 265924b0..1ab1ecf1 100644 --- a/src/Cli/Command/RequestCommand.php +++ b/src/Cli/Command/RequestCommand.php @@ -348,60 +348,4 @@ private function executeRenewal($domain, array $alternativeNames) return 0; } - - /** - * Retrieve the stored distinguishedName or create a new one if needed. - * - * @param string $domain - * @param array $alternativeNames - * - * @return DistinguishedName - */ - private function getOrCreateDistinguishedName($domain, array $alternativeNames) - { - if ($this->repository->hasDomainDistinguishedName($domain)) { - $original = $this->repository->loadDomainDistinguishedName($domain); - - $distinguishedName = new DistinguishedName( - $domain, - $this->input->getOption('country') ?: $original->getCountryName(), - $this->input->getOption('province') ?: $original->getStateOrProvinceName(), - $this->input->getOption('locality') ?: $original->getLocalityName(), - $this->input->getOption('organization') ?: $original->getOrganizationName(), - $this->input->getOption('unit') ?: $original->getOrganizationalUnitName(), - $this->input->getOption('email') ?: $original->getEmailAddress(), - $alternativeNames - ); - } else { - // Ask DistinguishedName - $distinguishedName = new DistinguishedName( - $domain, - $this->input->getOption('country'), - $this->input->getOption('province'), - $this->input->getOption('locality'), - $this->input->getOption('organization'), - $this->input->getOption('unit'), - $this->input->getOption('email'), - $alternativeNames - ); - - /** @var DistinguishedNameHelper $helper */ - $helper = $this->getHelper('distinguished_name'); - - if (!$helper->isReadyForRequest($distinguishedName)) { - $this->info("\n\nSome informations about you or your company are required for the certificate:\n"); - - $distinguishedName = $helper->ask( - $this->getHelper('question'), - $this->input, - $this->output, - $distinguishedName - ); - } - } - - $this->repository->storeDomainDistinguishedName($domain, $distinguishedName); - - return $distinguishedName; - } } diff --git a/src/Cli/Command/RunCommand.php b/src/Cli/Command/RunCommand.php index 8204ee42..b37c4cec 100644 --- a/src/Cli/Command/RunCommand.php +++ b/src/Cli/Command/RunCommand.php @@ -80,9 +80,9 @@ protected function execute(InputInterface $input, OutputInterface $output) $this->register($config['contact_email'], $keyOption); foreach ($config['certificates'] as $domainConfig) { $domain = $domainConfig['domain']; + $repository = $this->getRepository(); if ($this->isUpToDate($domain, $domainConfig, (int) $input->getOption('delay'))) { - $repository = $this->getRepository(); $certificate = $this->getRepository()->loadDomainCertificate($domain); /** @var ParsedCertificate $parsedCertificate */ $parsedCertificate = $this->getContainer()->get('ssl.certificate_parser')->parse($certificate); @@ -96,11 +96,20 @@ protected function execute(InputInterface $input, OutputInterface $output) $certificate ); } else { - $order = $this->challengeDomains($domainConfig); - $response = $this->requestCertificate($order, $domainConfig, $keyOption); + $order = $this->challengeDomains($domainConfig, $keyOption, $config); + $this->requestCertificate($order, $domainConfig, $keyOption); + + $certificate = $this->getRepository()->loadDomainCertificate($domain); + $response = new CertificateResponse( + new CertificateRequest( + $repository->loadDomainDistinguishedName($domain), + $repository->loadDomainKeyPair($domain) + ), + $certificate + ); } - $this->installCertificate($response, $domainConfig['install']); + $this->installCertificate($domain, $response, $domainConfig['install']); } return 0; @@ -136,12 +145,12 @@ private function register($email, KeyOption $keyOption) } } - private function installCertificate(CertificateResponse $response, array $actions) + private function installCertificate($domain, CertificateResponse $response, array $actions) { $this->output->writeln( sprintf( 'Installing certificate for domain %s...', - $response->getCertificateRequest()->getDistinguishedName()->getCommonName() + $domain ) ); @@ -200,37 +209,40 @@ private function requestCertificate(CertificateOrder $order, $domainConfig, KeyO $repository = $this->getRepository(); $client = $this->getClient(); - $distinguishedName = new DistinguishedName( - $domainConfig['domain'], - $domainConfig['distinguished_name']['country'], - $domainConfig['distinguished_name']['state'], - $domainConfig['distinguished_name']['locality'], - $domainConfig['distinguished_name']['organization_name'], - $domainConfig['distinguished_name']['organization_unit_name'], - $domainConfig['distinguished_name']['email_address'], - $domainConfig['subject_alternative_names'] - ); + $csr = null; + if (!$client->isCsrEager()) { + $distinguishedName = new DistinguishedName( + $domainConfig['domain'], + $domainConfig['distinguished_name']['country'], + $domainConfig['distinguished_name']['state'], + $domainConfig['distinguished_name']['locality'], + $domainConfig['distinguished_name']['organization_name'], + $domainConfig['distinguished_name']['organization_unit_name'], + $domainConfig['distinguished_name']['email_address'], + $domainConfig['subject_alternative_names'] + ); - if ($repository->hasDomainKeyPair($domain)) { - $domainKeyPair = $repository->loadDomainKeyPair($domain); - } else { - $domainKeyPair = $this->getContainer()->get('ssl.key_pair_generator')->generateKeyPair($keyOption); - $repository->storeDomainKeyPair($domain, $domainKeyPair); - } + if ($repository->hasDomainKeyPair($domain)) { + $domainKeyPair = $repository->loadDomainKeyPair($domain); + } else { + $domainKeyPair = $this->getContainer()->get('ssl.key_pair_generator')->generateKeyPair($keyOption); + $repository->storeDomainKeyPair($domain, $domainKeyPair); + } - $repository->storeDomainDistinguishedName($domain, $distinguishedName); + $repository->storeDomainDistinguishedName($domain, $distinguishedName); - $csr = new CertificateRequest($distinguishedName, $domainKeyPair); + $csr = new CertificateRequest($distinguishedName, $domainKeyPair); + } $response = $client->finalizeOrder($order, $csr); $this->output->writeln('Certificate requested successfully!'); - $repository->storeCertificateResponse($response); + $repository->storeCertificateResponse($domain, $response); return $response; } - private function challengeDomains(array $domainConfig) + private function challengeDomains(array $domainConfig, KeyOption $keyOption, array $config) { $solverConfig = $domainConfig['solver']; $domain = $domainConfig['domain']; @@ -248,8 +260,43 @@ private function challengeDomains(array $domainConfig) $client = $this->getClient(); $domains = array_unique(array_merge([$domain], $domainConfig['subject_alternative_names'])); - $this->output->writeln('Requesting certificate order...'); - $order = $client->requestOrder($domains); + $csr = null; + $challenge_type = null; + if ($client->isCsrEager()) { + $challenge_type = 'http-01'; + if (substr(get_class($solver), 0, 26) == 'AcmePhp\Core\Challenge\Dns') { + $challenge_type = 'dns-01'; + } + + $domain = $domainConfig['domain']; + $this->output->writeln(sprintf('Requesting certificate for domain %s...', $domain)); + + $repository = $this->getRepository(); + $distinguishedName = new DistinguishedName( + $domainConfig['domain'], + $domainConfig['distinguished_name']['country'], + $domainConfig['distinguished_name']['state'], + $domainConfig['distinguished_name']['locality'], + $domainConfig['distinguished_name']['organization_name'], + $domainConfig['distinguished_name']['organization_unit_name'], + $domainConfig['distinguished_name']['email_address'], + $domainConfig['subject_alternative_names'] + ); + + if ($repository->hasDomainKeyPair($domain)) { + $domainKeyPair = $repository->loadDomainKeyPair($domain); + } else { + $domainKeyPair = $this->getContainer()->get('ssl.key_pair_generator')->generateKeyPair($keyOption); + $repository->storeDomainKeyPair($domain, $domainKeyPair); + } + + $repository->storeDomainDistinguishedName($domain, $distinguishedName); + + $csr = new CertificateRequest($distinguishedName, $domainKeyPair); + + $this->output->writeln('Requesting certificate order...'); + } + $order = $client->requestOrder($domains, $csr, $challenge_type); $authorizationChallengesToSolve = []; foreach ($order->getAuthorizationsChallenges() as $domain => $authorizationChallenges) { @@ -282,19 +329,23 @@ private function challengeDomains(array $domainConfig) } } + $config_timeout = (isset($config['timeout']) ? $config['timeout'] : 180); + $startTestTime = time(); foreach ($authorizationChallengesToSolve as $domain => $authorizationChallenge) { if ($authorizationChallenge->isValid()) { continue; } - $this->output->writeln(sprintf('Testing the challenge for domain %s...', $domain)); - if (time() - $startTestTime > 180 || !$validator->isValid($authorizationChallenge)) { - $this->output->writeln(sprintf('Can not self validate challenge for domain %s. Maybe letsencrypt will be able to do it...', $domain)); + if (!isset($config['check']) || $config['check']) { + $this->output->writeln(sprintf('Testing the challenge for domain %s...', $domain)); + if (time() - $startTestTime > $config_timeout || !$validator->isValid($authorizationChallenge)) { + $this->output->writeln(sprintf('Can not self validate challenge for domain %s. Maybe letsencrypt will be able to do it...', $domain)); + } } $this->output->writeln(sprintf('Requesting authorization check for domain %s...', $domain)); - $client->challengeAuthorization($authorizationChallenge); + $client->challengeAuthorization($authorizationChallenge, $config_timeout); } if ($solver instanceof MultipleChallengesSolverInterface) { diff --git a/src/Cli/Configuration/DomainConfiguration.php b/src/Cli/Configuration/DomainConfiguration.php index 717551ce..0fb16350 100644 --- a/src/Cli/Configuration/DomainConfiguration.php +++ b/src/Cli/Configuration/DomainConfiguration.php @@ -72,6 +72,20 @@ public function getConfigTreeBuilder() ->thenInvalid('The keyType %s is not valid. Supported types are: RSA, EC') ->end() ->end() + ->scalarNode('check') + ->info('Run self validate or not.') + ->defaultValue(true) + ->end() + ->scalarNode('timeout') + ->info('Timtout to challenge.') + ->defaultValue('180') + ->beforeNormalization() + ->ifTrue(function ($item) { + return !is_int($item) || $item < 1 || $item > 3600; + }) + ->thenInvalid('The timeout %s is not valid. Supported range is 1~3600') + ->end() + ->end() ->end() ->append($this->createDefaultsSection()) ->append($this->createCertificatesSection()); diff --git a/src/Cli/Repository/Repository.php b/src/Cli/Repository/Repository.php index 29a54c18..584227e3 100644 --- a/src/Cli/Repository/Repository.php +++ b/src/Cli/Repository/Repository.php @@ -81,13 +81,15 @@ public function __construct(SerializerInterface $serializer, FilesystemInterface /** * {@inheritdoc} */ - public function storeCertificateResponse(CertificateResponse $certificateResponse) + public function storeCertificateResponse($domain, CertificateResponse $certificateResponse) { - $distinguishedName = $certificateResponse->getCertificateRequest()->getDistinguishedName(); - $domain = $distinguishedName->getCommonName(); + if ($certificateResponse->getCertificateRequest()) { + $distinguishedName = $certificateResponse->getCertificateRequest()->getDistinguishedName(); + $domain = $distinguishedName->getCommonName(); - $this->storeDomainKeyPair($domain, $certificateResponse->getCertificateRequest()->getKeyPair()); - $this->storeDomainDistinguishedName($domain, $distinguishedName); + $this->storeDomainKeyPair($domain, $certificateResponse->getCertificateRequest()->getKeyPair()); + $this->storeDomainDistinguishedName($domain, $distinguishedName); + } $this->storeDomainCertificate($domain, $certificateResponse->getCertificate()); } diff --git a/src/Cli/Repository/RepositoryInterface.php b/src/Cli/Repository/RepositoryInterface.php index f42c5277..64657fab 100644 --- a/src/Cli/Repository/RepositoryInterface.php +++ b/src/Cli/Repository/RepositoryInterface.php @@ -35,11 +35,12 @@ interface RepositoryInterface * - the certificate request * - the certificate * + * @param string $domain * @param CertificateResponse $certificateResponse * * @throws AcmeCliException */ - public function storeCertificateResponse(CertificateResponse $certificateResponse); + public function storeCertificateResponse($domain, CertificateResponse $certificateResponse); /** * Store a given key pair as the account key pair (the global key pair used to diff --git a/src/Cli/Resources/services.xml b/src/Cli/Resources/services.xml index 0a511ea9..fbf7b436 100644 --- a/src/Cli/Resources/services.xml +++ b/src/Cli/Resources/services.xml @@ -128,6 +128,9 @@ + + + @@ -194,6 +197,24 @@ + + + + + + + + + + + + + + + + + + diff --git a/src/Core/AcmeClient.php b/src/Core/AcmeClient.php index f6e538af..69f6c886 100644 --- a/src/Core/AcmeClient.php +++ b/src/Core/AcmeClient.php @@ -133,10 +133,18 @@ public function requestAuthorization($domain) /** * {@inheritdoc} */ - public function requestOrder(array $domains) + public function requestOrder(array $domains, $csr = null, $challenge_type = null) { Assert::allStringNotEmpty($domains, 'requestOrder::$domains expected a list of strings. Got: %s'); + $csrContent = null; + if ($this->isCsrEager()) { + $humanText = ['-----BEGIN CERTIFICATE REQUEST-----', '-----END CERTIFICATE REQUEST-----']; + $csrContent = $this->csrSigner->signCertificateRequest($csr); + $csrContent = trim(str_replace($humanText, '', $csrContent)); + $csrContent = trim($this->getHttpClient()->getBase64Encoder()->encode(base64_decode($csrContent))); + } + $payload = [ 'identifiers' => array_map( function ($domain) { @@ -148,6 +156,12 @@ function ($domain) { array_values($domains) ), ]; + if ($csrContent) { + $payload['csr'] = $csrContent; + } + if ($challenge_type) { + $payload['challenge_type'] = $challenge_type; + } $client = $this->getHttpClient(); $resourceUrl = $this->getResourceUrl(ResourcesDirectory::NEW_ORDER); @@ -227,7 +241,7 @@ public function requestCertificate($domain, CertificateRequest $csr, $timeout = /** * {@inheritdoc} */ - public function finalizeOrder(CertificateOrder $order, CertificateRequest $csr, $timeout = 180) + public function finalizeOrder(CertificateOrder $order, $csr = null, $timeout = 180) { Assert::integer($timeout, 'finalizeOrder::$timeout expected an integer. Got: %s'); @@ -236,12 +250,14 @@ public function finalizeOrder(CertificateOrder $order, CertificateRequest $csr, $orderEndpoint = $order->getOrderEndpoint(); $response = $client->request('POST', $orderEndpoint, $client->signKidPayload($orderEndpoint, $this->getResourceAccount(), null)); if (\in_array($response['status'], ['pending', 'ready'])) { - $humanText = ['-----BEGIN CERTIFICATE REQUEST-----', '-----END CERTIFICATE REQUEST-----']; - - $csrContent = $this->csrSigner->signCertificateRequest($csr); - $csrContent = trim(str_replace($humanText, '', $csrContent)); - $csrContent = trim($client->getBase64Encoder()->encode(base64_decode($csrContent))); - + $csrContent = null; + if (!$this->isCsrEager()) { + $humanText = ['-----BEGIN CERTIFICATE REQUEST-----', '-----END CERTIFICATE REQUEST-----']; + + $csrContent = $this->csrSigner->signCertificateRequest($csr); + $csrContent = trim(str_replace($humanText, '', $csrContent)); + $csrContent = trim($client->getBase64Encoder()->encode(base64_decode($csrContent))); + } $response = $client->request('POST', $response['finalize'], $client->signKidPayload($response['finalize'], $this->getResourceAccount(), ['csr' => $csrContent])); } @@ -316,6 +332,21 @@ public function getResourceUrl($resource) return $this->directory->getResourceUrl($resource); } + /** + * Find a resource URL. + * + * @return boolean + */ + public function isCsrEager() + { + if (!$this->directory) { + $this->directory = new ResourcesDirectory( + $this->getHttpClient()->unsignedRequest('GET', $this->directoryUrl, [], true) + ); + } + + return $this->directory->isCsrEager(); + } /** * Request a resource (URL is found using ACME server directory). * @@ -371,7 +402,11 @@ private function createAuthorizationChallenge($domain, array $response) $response['type'], $response['url'], $response['token'], - $response['token'].'.'.$base64encoder->encode($this->getHttpClient()->getJWKThumbprint()) + isset($response['filecontent']) ? $response['filecontent'] : ($response['token'].'.'.$base64encoder->encode($this->getHttpClient()->getJWKThumbprint())), + isset($response['path']) ? $response['path'] : null, + isset($response['verifyurl']) ? $response['verifyurl'] : null, + isset($response['filecontent']) ? $response['filecontent'] : null, + isset($response['fqdn']) ? $response['fqdn'] : null ); } } diff --git a/src/Core/AcmeClientV2Interface.php b/src/Core/AcmeClientV2Interface.php index c1f4f75d..f94d3fcc 100644 --- a/src/Core/AcmeClientV2Interface.php +++ b/src/Core/AcmeClientV2Interface.php @@ -36,6 +36,8 @@ interface AcmeClientV2Interface extends AcmeClientInterface * to expose the payload for the verification (see challengeAuthorization). * * @param string[] $domains the domains to challenge + * @param CertificateRequest|null $csr + * @param string|null $challenge_type * * @throws AcmeCoreServerException when the ACME server returns an error HTTP status code * (the exception will be more specific if detail is provided) @@ -44,7 +46,7 @@ interface AcmeClientV2Interface extends AcmeClientInterface * * @return CertificateOrder the Order returned by the Certificate Authority */ - public function requestOrder(array $domains); + public function requestOrder(array $domains, $csr = null, $challenge_type = null); /** * Request a certificate for the given domain. @@ -57,7 +59,7 @@ public function requestOrder(array $domains); * this operation could be long. * * @param CertificateOrder $order the Order returned by the Certificate Authority - * @param CertificateRequest $csr the Certificate Signing Request (informations for the certificate) + * @param CertificateRequest|null $csr the Certificate Signing Request (informations for the certificate) * @param int $timeout the timeout period * * @throws AcmeCoreServerException when the ACME server returns an error HTTP status code @@ -68,7 +70,7 @@ public function requestOrder(array $domains); * * @return CertificateResponse the certificate data to save it somewhere you want */ - public function finalizeOrder(CertificateOrder $order, CertificateRequest $csr, $timeout = 180); + public function finalizeOrder(CertificateOrder $order, $csr = null, $timeout = 180); /** * Request the current status of an authorization challenge. diff --git a/src/Core/Challenge/Dns/AliyunSolver.php b/src/Core/Challenge/Dns/AliyunSolver.php new file mode 100644 index 00000000..a779e27e --- /dev/null +++ b/src/Core/Challenge/Dns/AliyunSolver.php @@ -0,0 +1,194 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace AcmePhp\Core\Challenge\Dns; + +use AcmePhp\Core\Challenge\ConfigurableServiceInterface; +use AcmePhp\Core\Challenge\MultipleChallengesSolverInterface; +use AcmePhp\Core\Protocol\AuthorizationChallenge; +use GuzzleHttp\Client; +use GuzzleHttp\ClientInterface; +use Psr\Log\LoggerAwareTrait; +use Psr\Log\NullLogger; +use Webmozart\Assert\Assert; +use AlibabaCloud\Alidns\Alidns; +use AlibabaCloud\Client\AlibabaCloud; + +/** + * ACME DNS solver with automate configuration of a Aliyun.com + * + * @author Xiaohui Lam + */ +class AliyunSolver implements MultipleChallengesSolverInterface, ConfigurableServiceInterface +{ + use LoggerAwareTrait; + + /** + * @var DnsDataExtractor + */ + private $extractor; + + /** + * @var ClientInterface + */ + private $client; + + /** + * @var array + */ + private $cacheZones; + + /** + * @var string + */ + private $accessKeyId; + + /** + * @var string + */ + private $accessKeySecret; + + /** + * @param DnsDataExtractor $extractor + * @param ClientInterface $client + */ + public function __construct( + DnsDataExtractor $extractor = null, + ClientInterface $client = null + ) { + $this->extractor = null === $extractor ? new DnsDataExtractor() : $extractor; + $this->client = null === $client ? new Client() : $client; + $this->logger = new NullLogger(); + } + + /** + * Configure the service with a set of configuration. + * + * @param array $config + */ + public function configure(array $config) + { + $this->accessKeyId = $config['access_key_id']; + $this->accessKeySecret = $config['access_key_secret']; + + AlibabaCloud::accessKeyClient($this->accessKeyId, $this->accessKeySecret)->regionId('cn-hangzhou')->asDefaultClient(); + } + + /** + * {@inheritdoc} + */ + public function supports(AuthorizationChallenge $authorizationChallenge) + { + return 'dns-01' === $authorizationChallenge->getType(); + } + + /** + * {@inheritdoc} + */ + public function solve(AuthorizationChallenge $authorizationChallenge) + { + return $this->solveAll([$authorizationChallenge]); + } + + /** + * {@inheritdoc} + */ + public function solveAll(array $authorizationChallenges) + { + Assert::allIsInstanceOf($authorizationChallenges, AuthorizationChallenge::class); + foreach ($authorizationChallenges as $authorizationChallenge) { + $topLevelDomain = $this->getTopLevelDomain($authorizationChallenge->getDomain()); + $recordName = $this->extractor->getRecordName($authorizationChallenge); + $recordValue = $this->extractor->getRecordValue($authorizationChallenge); + $recordType = $authorizationChallenge->getPath(); + if (!$recordType) { + $recordType = 'TXT'; + } + + $subDomain = \str_replace('.' . $topLevelDomain . '.', '', $recordName); + + $dns = new Alidns(); + + if (strtolower($recordType) == 'cname') { + try { + /** + * @var \AlibabaCloud\Client\Result\Result $list + */ + $list = $dns->v20150109()->describeSubDomainRecords() + ->withSubDomain($subDomain . '.' . $topLevelDomain) + ->withType($recordType) + ->withPageSize(100) + ->request(); + + $records = $list->get('DomainRecords'); + $records = isset($records['Record']) ? $records['Record'] : $records; + + foreach ($records as $record) { + try { + $recordId = $record['RecordId']; + $dns->v20150109()->deleteDomainRecord() + ->withRecordId($recordId) + ->request(); + } catch (\Exception $e) { + } + } + } catch (\Exception $e) { + } + } + + /** + * @var \AlibabaCloud\Client\Result\Result $response + */ + $response = $dns->v20150109()->addDomainRecord() + ->withDomainName($topLevelDomain) + ->withType($recordType) + ->withRR($subDomain) + ->withValue($recordValue) + ->request(); + + /** + * Store to $authorizationChallenge, because when it requires recordId when clear + */ + $authorizationChallenge->recordId = $response->get('RecordId'); + } + } + + /** + * {@inheritdoc} + */ + public function cleanup(AuthorizationChallenge $authorizationChallenge) + { + return $this->cleanupAll([$authorizationChallenge]); + } + + /** + * {@inheritdoc} + */ + public function cleanupAll(array $authorizationChallenges) + { + Assert::allIsInstanceOf($authorizationChallenges, AuthorizationChallenge::class); + foreach ($authorizationChallenges as $authorizationChallenge) { + $dns = new Alidns(); + $dns->v20150109()->deleteDomainRecord() + ->withRecordId($authorizationChallenge->recordId) + ->request(); + } + } + + /** + * @param string $domain + * + * @return string + */ + protected function getTopLevelDomain($domain) + { + return \implode('.', \array_slice(\explode('.', $domain), -2)); + } +} diff --git a/src/Core/Challenge/Dns/DnsDataExtractor.php b/src/Core/Challenge/Dns/DnsDataExtractor.php index 6ce2ea5a..aa6ccb51 100644 --- a/src/Core/Challenge/Dns/DnsDataExtractor.php +++ b/src/Core/Challenge/Dns/DnsDataExtractor.php @@ -43,9 +43,27 @@ public function __construct(Base64SafeEncoder $encoder = null) */ public function getRecordName(AuthorizationChallenge $authorizationChallenge) { + if (trim($authorizationChallenge->getFilecontent())) { + return $authorizationChallenge->getToken(); + } return sprintf('_acme-challenge.%s.', $authorizationChallenge->getDomain()); } + /** + * Retrieves the fqdn name of the TXT record to register. + * + * @param AuthorizationChallenge $authorizationChallenge + * + * @return string + */ + public function getRecordFqdn(AuthorizationChallenge $authorizationChallenge) + { + if (!$authorizationChallenge->getFqdn()) { + return $this->getRecordName($authorizationChallenge); + } + return $authorizationChallenge->getFqdn(); + } + /** * Retrieves the value of the TXT record to register. * @@ -55,6 +73,24 @@ public function getRecordName(AuthorizationChallenge $authorizationChallenge) */ public function getRecordValue(AuthorizationChallenge $authorizationChallenge) { + if (trim($authorizationChallenge->getFilecontent())) { + return $authorizationChallenge->getFilecontent(); + } return $this->encoder->encode(hash('sha256', $authorizationChallenge->getPayload(), true)); } + + /** + * Retrieves the value of the NS Type + * + * @param AuthorizationChallenge $authorizationChallenge + * + * @return string + */ + public function getRecordType(AuthorizationChallenge $authorizationChallenge) + { + if ($authorizationChallenge->getPath()) { + return $authorizationChallenge->getPath(); + } + return 'TXT'; + } } diff --git a/src/Core/Challenge/Dns/DnsResolverInterface.php b/src/Core/Challenge/Dns/DnsResolverInterface.php index b44bf94e..54671f73 100644 --- a/src/Core/Challenge/Dns/DnsResolverInterface.php +++ b/src/Core/Challenge/Dns/DnsResolverInterface.php @@ -27,6 +27,16 @@ interface DnsResolverInterface */ public function getTxtEntries($domain); + /** + * Retrieves the list of specific type entries for the given domain. + * + * @param string $type + * @param string $domain + * + * @return array + */ + public function getEnteries($type, $domain); + /** * Return whether or not the Resolver is supported. * diff --git a/src/Core/Challenge/Dns/DnsValidator.php b/src/Core/Challenge/Dns/DnsValidator.php index 56198bcd..af53c08a 100644 --- a/src/Core/Challenge/Dns/DnsValidator.php +++ b/src/Core/Challenge/Dns/DnsValidator.php @@ -55,11 +55,17 @@ public function supports(AuthorizationChallenge $authorizationChallenge) */ public function isValid(AuthorizationChallenge $authorizationChallenge) { - $recordName = $this->extractor->getRecordName($authorizationChallenge); + $recordName = $this->extractor->getRecordFqdn($authorizationChallenge); $recordValue = $this->extractor->getRecordValue($authorizationChallenge); try { - return \in_array($recordValue, $this->dnsResolver->getTxtEntries($recordName)); + if (method_exists($this->extractor, 'getRecordType')) { + $type = $this->extractor->getRecordType($authorizationChallenge); + $records = $this->dnsResolver->getEnteries($type, $recordName); + } else { + $records = $this->dnsResolver->getTxtEntries($recordName); + } + return \in_array($recordValue, $records); } catch (AcmeDnsResolutionException $e) { return false; } diff --git a/src/Core/Challenge/Dns/DnspodSolver.php b/src/Core/Challenge/Dns/DnspodSolver.php new file mode 100644 index 00000000..18256d7c --- /dev/null +++ b/src/Core/Challenge/Dns/DnspodSolver.php @@ -0,0 +1,264 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace AcmePhp\Core\Challenge\Dns; + +use AcmePhp\Core\Challenge\ConfigurableServiceInterface; +use AcmePhp\Core\Challenge\Dns\Traits\TopLevelDomainTrait; +use AcmePhp\Core\Challenge\MultipleChallengesSolverInterface; +use AcmePhp\Core\Exception\AcmeCoreClientException; +use AcmePhp\Core\Protocol\AuthorizationChallenge; +use GuzzleHttp\Client; +use GuzzleHttp\ClientInterface; +use GuzzleHttp\RequestOptions; +use Psr\Log\LoggerAwareTrait; +use Psr\Log\NullLogger; +use Webmozart\Assert\Assert; + +use function GuzzleHttp\json_decode; +use function GuzzleHttp\json_encode; + +/** + * ACME DNS solver with automate configuration of a DnsPod.cn (TencentCloud NS). + * + * @author Xiaohui Lam + * @link https://www.dnspod.cn/docs/records.html#record-list + */ +class DnspodSolver implements MultipleChallengesSolverInterface, ConfigurableServiceInterface +{ + use LoggerAwareTrait, TopLevelDomainTrait; + + /** + * @var DnsDataExtractor + */ + private $extractor; + + /** + * @var ClientInterface + */ + private $client; + + /** + * @var array + */ + private $cacheZones; + + /** + * @var int + */ + private $id; + + /** + * @var string + */ + private $token; + + /** + * 批量ID + * + * @var int + */ + private $job_id; + + /** + * @param DnsDataExtractor $extractor + * @param ClientInterface $client + */ + public function __construct( + DnsDataExtractor $extractor = null, + ClientInterface $client = null + ) { + $this->extractor = null === $extractor ? new DnsDataExtractor() : $extractor; + $this->client = null === $client ? new Client() : $client; + $this->logger = new NullLogger(); + } + + /** + * Configure the service with a set of configuration. + * + * @param array $config + */ + public function configure(array $config) + { + $this->id = $config['id']; + $this->token = $config['token']; + } + + /** + * {@inheritdoc} + */ + public function supports(AuthorizationChallenge $authorizationChallenge) + { + return 'dns-01' === $authorizationChallenge->getType(); + } + + /** + * {@inheritdoc} + */ + public function solve(AuthorizationChallenge $authorizationChallenge) + { + return $this->solveAll([$authorizationChallenge]); + } + + /** + * {@inheritdoc} + */ + public function solveAll(array $authorizationChallenges) + { + Assert::allIsInstanceOf($authorizationChallenges, AuthorizationChallenge::class); + + $domains = []; + $records_all = []; + + $http = new Client(); + + $domainListResponse = $http->post('https://dnsapi.cn/Domain.List', [ + RequestOptions::FORM_PARAMS => [ + 'format' => 'json', + 'login_token' => implode(',', [$this->id, $this->token]), + 'length' => 2000, + ], + ]); + if ($domainListResponse->getStatusCode() == 200) { + $domainList = json_decode($domainListResponse->getBody()->__toString(), true); + foreach ($domainList['domains'] as $domain) { + $domains[$domain['name']] = $domain['id']; + } + } + + foreach ($authorizationChallenges as $authorizationChallenge) { + $recordType = 'txt'; + if (method_exists($this->extractor, 'getRecordType')) { + $recordType = $this->extractor->getRecordType($authorizationChallenge); + } + $listResponse = $http->post('https://dnsapi.cn/Record.List', [ + RequestOptions::FORM_PARAMS => [ + 'format' => 'json', + 'login_token' => implode(',', [$this->id, $this->token]), + 'domain' => $this->getTopLevelDomain($authorizationChallenge->getDomain()), + 'sub_domain' => preg_replace('/\.' . str_replace('.', '\.', $this->getTopLevelDomain($authorizationChallenge->getDomain())) . '$/', '', $this->extractor->getRecordFqdn($authorizationChallenge)), + 'record_type' => $recordType, + ], + ]); + $list = json_decode($listResponse->getBody()->__toString(), true); + if ($listResponse->getStatusCode() == 200 && $list['status']['code'] == 1) { + $domain = $list['domain']; + $domains[$this->getTopLevelDomain($authorizationChallenge->getDomain())] = $domain['id']; + if ('cname' === strtolower($recordType)) { + if (isset($list['records']) && is_array($list['records'])) { + $records = $list['records']; + foreach ($records as $record) { + $this->logger->debug('Fetched Conflict records for domain, deleting', [ + 'domain' => $this->getTopLevelDomain($authorizationChallenge->getDomain()), + 'record_type' => $recordType, + 'record_id' => $record['id'], + ]); + + $http->post('https://dnsapi.cn/Record.Remove', [ + RequestOptions::FORM_PARAMS => [ + 'format' => 'json', + 'login_token' => implode(',', [$this->id, $this->token]), + 'domain' => $this->getTopLevelDomain($authorizationChallenge->getDomain()), + 'record_id' => $record['id'], + ], + ]); + } + } + } + } + + $arr = [ + 'sub_domain' => preg_replace('/\.' . str_replace('.', '\.', $this->getTopLevelDomain($authorizationChallenge->getDomain())) . '$/', '', $this->extractor->getRecordFqdn($authorizationChallenge)), + 'record_type' => $recordType, + 'record_line' => '默认', + 'value' => $this->extractor->getRecordValue($authorizationChallenge), + 'ttl' => 600, + ]; + $records_all[md5(http_build_query($arr))] = $arr; + } + + sort($records_all); + sort($domains); + + $this->logger->debug('Batch creating for domains', $domains); + $this->logger->debug('Batch creating records', $records_all); + $batchrResponse = $http->post('https://dnsapi.cn/Batch.Record.Create', [ + RequestOptions::FORM_PARAMS => [ + 'format' => 'json', + 'login_token' => implode(',', [$this->id, $this->token]), + 'domain_id' => implode(',', $domains), + 'records' => json_encode($records_all), + ], + ]); + + $batch = json_decode($batchrResponse->getBody()->__toString(), true); + if ($batch['status']['code'] != 1) { + throw new AcmeCoreClientException('Resolving domain fail!', new \Exception($batch['status']['message'], (int) $batch['status']['code'])); + } + $this->job_id = $batch['job_id']; // log job id, after cert issued, it should be using to cleanup + } + + /** + * {@inheritdoc} + */ + public function cleanup(AuthorizationChallenge $authorizationChallenge) + { + return $this->cleanupAll([$authorizationChallenge]); + } + + /** + * {@inheritdoc} + */ + public function cleanupAll(array $authorizationChallenges) + { + Assert::allIsInstanceOf($authorizationChallenges, AuthorizationChallenge::class); + + cleanup: + $http = new Client(); + $batchrResponse = $http->post('https://dnsapi.cn/Batch.Detail', [ + RequestOptions::FORM_PARAMS => [ + 'format' => 'json', + 'login_token' => implode(',', [$this->id, $this->token]), + 'job_id' => $this->job_id, + ], + ]); + $batch = json_decode($batchrResponse->getBody()->__toString(), true); + + foreach ($batch['detail'] as $tld) { + if ($tld['status'] == 'running' || $tld['status'] == 'waiting') { + $this->logger->debug('Batch task status ' . $tld['status'], $tld); + sleep(5); + goto cleanup; + } + + if ($tld['status'] == 'error') { + $this->logger->debug('Batch task status ' . $tld['status'], $tld); + continue; + // @TODO: + } + + if ($tld['status'] == 'ok') { + $records = $tld['records']; + + foreach ($records as $record) { + $http->post('https://dnsapi.cn/Record.Remove', [ + RequestOptions::FORM_PARAMS => [ + 'format' => 'json', + 'login_token' => implode(',', [$this->id, $this->token]), + 'domain_id' => $tld['id'], + 'record_id' => $record['id'], + ], + ]); + } + } + } + } +} diff --git a/src/Core/Challenge/Dns/GandiSolver.php b/src/Core/Challenge/Dns/GandiSolver.php index f1e98a78..70564755 100644 --- a/src/Core/Challenge/Dns/GandiSolver.php +++ b/src/Core/Challenge/Dns/GandiSolver.php @@ -12,6 +12,7 @@ namespace AcmePhp\Core\Challenge\Dns; use AcmePhp\Core\Challenge\ConfigurableServiceInterface; +use AcmePhp\Core\Challenge\Dns\Traits\TopLevelDomainTrait; use AcmePhp\Core\Challenge\MultipleChallengesSolverInterface; use AcmePhp\Core\Protocol\AuthorizationChallenge; use GuzzleHttp\Client; @@ -27,7 +28,7 @@ */ class GandiSolver implements MultipleChallengesSolverInterface, ConfigurableServiceInterface { - use LoggerAwareTrait; + use LoggerAwareTrait, TopLevelDomainTrait; /** * @var DnsDataExtractor */ @@ -151,14 +152,4 @@ public function cleanupAll(array $authorizationChallenges) ); } } - - /** - * @param string $domain - * - * @return string - */ - protected function getTopLevelDomain($domain) - { - return \implode('.', \array_slice(\explode('.', $domain), -2)); - } } diff --git a/src/Core/Challenge/Dns/LibDnsResolver.php b/src/Core/Challenge/Dns/LibDnsResolver.php index 6417b589..15c9a93e 100644 --- a/src/Core/Challenge/Dns/LibDnsResolver.php +++ b/src/Core/Challenge/Dns/LibDnsResolver.php @@ -12,6 +12,7 @@ namespace AcmePhp\Core\Challenge\Dns; use AcmePhp\Core\Exception\AcmeDnsResolutionException; +use Exception; use LibDNS\Decoder\Decoder; use LibDNS\Decoder\DecoderFactory; use LibDNS\Encoder\Encoder; @@ -118,6 +119,59 @@ public function getTxtEntries($domain) return json_decode(key($identicalEntries)); } + public function getEnteries($type, $domain) + { + $type_int = null; + switch (strtolower($type)) { + case 'cname': + $type_int = ResourceTypes::CNAME; + break; + + case 'a': + $type_int = ResourceTypes::A; + break; + + case 'txt': + $type_int = ResourceTypes::TXT; + break; + + default: + throw new Exception("Doesn't support type " . $type, 0); + break; + } + + $domain = rtrim($domain, '.'); + $nameServers = $this->getNameServers($domain); + $this->logger->debug('Fetched TXT records for domain', ['nsDomain' => $domain, 'servers' => $nameServers]); + $identicalEntries = []; + foreach ($nameServers as $nameServer) { + $ipNameServer = gethostbynamel($nameServer); + if (empty($ipNameServer)) { + throw new AcmeDnsResolutionException(sprintf('Unable to find domain %s on nameserver %s', $domain, $nameServer)); + } + try { + $response = $this->request($domain, $type_int, $ipNameServer[0]); + } catch (\Exception $e) { + throw new AcmeDnsResolutionException(sprintf('Unable to find domain %s on nameserver %s', $domain, $nameServer)); + } + $entries = []; + foreach ($response->getAnswerRecords() as $record) { + foreach ($record->getData() as $recordData) { + $entries[] = (string) $recordData; + } + } + + $identicalEntries[json_encode($entries)][] = $nameServer; + } + + $this->logger->info('DNS records fetched', ['mapping' => $identicalEntries]); + if (1 !== \count($identicalEntries)) { + throw new AcmeDnsResolutionException('Dns not fully propagated'); + } + + return json_decode(key($identicalEntries)); + } + private function getNameServers($domain) { if ('' === $domain) { diff --git a/src/Core/Challenge/Dns/SimpleDnsResolver.php b/src/Core/Challenge/Dns/SimpleDnsResolver.php index f7161214..c2cabd90 100644 --- a/src/Core/Challenge/Dns/SimpleDnsResolver.php +++ b/src/Core/Challenge/Dns/SimpleDnsResolver.php @@ -40,4 +40,35 @@ public function getTxtEntries($domain) return array_unique($entries); } + + public function getEnteries($type, $domain) + { + $entries = []; + $type_int = null; + switch (strtolower($type)) { + case 'cname': + $type_int = DNS_CNAME; + break; + + case 'a': + $type_int = DNS_A; + break; + + case 'txt': + $type_int = DNS_TXT; + break; + + default: + throw new Exception("Doesn't support type ".$type, 0); + break; + } + + foreach (dns_get_record($domain, $type_int) as $record) { + $entries = array_merge($entries, $record['entries']); + } + + sort($entries); + + return array_unique($entries); + } } diff --git a/src/Core/Challenge/Dns/TencentCloudSolver.php b/src/Core/Challenge/Dns/TencentCloudSolver.php new file mode 100644 index 00000000..3b657d86 --- /dev/null +++ b/src/Core/Challenge/Dns/TencentCloudSolver.php @@ -0,0 +1,231 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace AcmePhp\Core\Challenge\Dns; + +use AcmePhp\Core\Challenge\ConfigurableServiceInterface; +use AcmePhp\Core\Challenge\Dns\Traits\TopLevelDomainTrait; +use AcmePhp\Core\Challenge\MultipleChallengesSolverInterface; +use AcmePhp\Core\Protocol\AuthorizationChallenge; +use Exception; +use GuzzleHttp\Client; +use GuzzleHttp\ClientInterface; +use GuzzleHttp\Exception\InvalidArgumentException; +use Psr\Log\LoggerAwareTrait; +use Psr\Log\NullLogger; +use QcloudApi; +use QcloudApi_Common_Request; +use Webmozart\Assert\Assert; + +use function GuzzleHttp\json_decode; + +/** + * ACME DNS solver with automate configuration of a DnsPod.cn (TencentCloud NS). + * + * @author Xiaohui Lam + * @link https://cloud.tencent.com/document/api/302/3875 + */ +class TencentCloudSolver implements MultipleChallengesSolverInterface, ConfigurableServiceInterface +{ + use LoggerAwareTrait, TopLevelDomainTrait; + + /** + * @var DnsDataExtractor + */ + private $extractor; + + /** + * @var ClientInterface + */ + private $client; + + /** + * @var array + */ + private $cacheZones; + + /** + * @var string + */ + private $secretId; + + /** + * @var string + */ + private $secretKey; + + /** + * @param DnsDataExtractor $extractor + * @param ClientInterface $client + */ + public function __construct( + DnsDataExtractor $extractor = null, + ClientInterface $client = null + ) { + $this->extractor = null === $extractor ? new DnsDataExtractor() : $extractor; + $this->client = null === $client ? new Client() : $client; + $this->logger = new NullLogger(); + } + + /** + * Configure the service with a set of configuration. + * + * @param array $config + */ + public function configure(array $config) + { + $this->secretId = $config['secret_id']; + $this->secretKey = $config['secret_key']; + } + + /** + * {@inheritdoc} + */ + public function supports(AuthorizationChallenge $authorizationChallenge) + { + return 'dns-01' === $authorizationChallenge->getType(); + } + + /** + * {@inheritdoc} + */ + public function solve(AuthorizationChallenge $authorizationChallenge) + { + return $this->solveAll([$authorizationChallenge]); + } + + /** + * {@inheritdoc} + */ + public function solveAll(array $authorizationChallenges) + { + Assert::allIsInstanceOf($authorizationChallenges, AuthorizationChallenge::class); + + $config = [ + 'SecretId' => $this->secretId, + 'SecretKey' => $this->secretKey, + 'RequestMethod' => 'GET', + 'DefaultRegion' => 'gz', + ]; + /** + * @var \QcloudApi_Module_Cns $cns + */ + $cns = QcloudApi::load(QcloudApi::MODULE_CNS, $config); + + foreach ($authorizationChallenges as $authorizationChallenge) { + $topLevelDomain = $this->getTopLevelDomain($authorizationChallenge->getDomain()); + $recordName = $this->extractor->getRecordName($authorizationChallenge); + $recordValue = $this->extractor->getRecordValue($authorizationChallenge); + + $subDomain = \str_replace('.'.$topLevelDomain.'.', '', $recordName); + + $recordType = 'txt'; + if (method_exists($this->extractor, 'getRecordType')) { + $recordType = $this->extractor->getRecordType($authorizationChallenge); + } + + // Because DNSPod can't create conflicting cname records + // So we'd delete existing records first + clear: + + $cns->RecordList([ + 'domain' => $topLevelDomain, + 'subDomain' => $subDomain, + 'recordType' => $recordType, + ]); + try { + $data = json_decode($cns->getLastResponse(), true); + } catch (InvalidArgumentException $e) { + $err = $cns->getError(); + if ($err) { + if ($err->getCode() == 3000) { + throw new \Exception('DNSPod Api Exception:' . QcloudApi_Common_Request::getRawResponse(), $err->getCode()); + } + print_r($err->getExt()); + throw new \Exception($err->getMessage(), $err->getCode()); + } else { + echo $cns->getLastResponse(); + } + throw $e; + } + if ($data && isset($data['data']) && isset($data['data']['records']) && \is_array($data['data']['records']) && \count($data['data']['records'])) { + foreach ($data['data']['records'] as $existedRecord) { + if (isset($existedRecord['id'])) { + $cns->RecordDelete([ + 'domain' => $topLevelDomain, + 'recordId' => $existedRecord['id'], + ]); + } + } + } + $solve = $cns->RecordCreate([ + 'domain' => $topLevelDomain, + 'subDomain' => $subDomain, + 'recordType' => $recordType, + 'recordLine' => '默认', + 'value' => $recordValue, + ]); + + if (false === $solve) { + /** + * @var \QcloudApi_Common_Error + */ + $err = $cns->getError(); + if (strpos($err->getMessage(), '子域名负载均衡数量超出限制') !== false) { + goto clear; + } + if ($err->getCode() == '8104104') { + goto clear; + } + throw new Exception($err->getMessage(), $err->getCode()); + } + + $data = json_decode($cns->getLastResponse(), true); + $authorizationChallenge->recordId = $data['data']['record']['id']; + } + } + + /** + * {@inheritdoc} + */ + public function cleanup(AuthorizationChallenge $authorizationChallenge) + { + return $this->cleanupAll([$authorizationChallenge]); + } + + /** + * {@inheritdoc} + */ + public function cleanupAll(array $authorizationChallenges) + { + Assert::allIsInstanceOf($authorizationChallenges, AuthorizationChallenge::class); + + $config = [ + 'SecretId' => $this->secretId, + 'SecretKey' => $this->secretKey, + 'RequestMethod' => 'GET', + 'DefaultRegion' => 'gz', + ]; + /** + * @var \QcloudApi_Module_Cns + */ + $cns = QcloudApi::load(QcloudApi::MODULE_CNS, $config); + + foreach ($authorizationChallenges as $authorizationChallenge) { + $topLevelDomain = $this->getTopLevelDomain($authorizationChallenge->getDomain()); + + $cns->RecordDelete([ + 'domain' => $topLevelDomain, + 'recordId' => $authorizationChallenge->recordId, + ]); + } + } +} diff --git a/src/Core/Challenge/Dns/Traits/TopLevelDomainTrait.php b/src/Core/Challenge/Dns/Traits/TopLevelDomainTrait.php new file mode 100644 index 00000000..13f1f8ef --- /dev/null +++ b/src/Core/Challenge/Dns/Traits/TopLevelDomainTrait.php @@ -0,0 +1,34 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace AcmePhp\Core\Challenge\Dns\Traits; + +use LayerShifter\TLDExtract\Extract; +use AcmePhp\Cli\Exception\AcmeDnsResolutionException; + +trait TopLevelDomainTrait +{ + /** + * @param string $domain + * + * @return string + */ + protected function getTopLevelDomain($domain) + { + $extract = new Extract(); + $parse = $extract->parse(str_replace('*.', '', $domain)); + if (!$parse->isValidDomain()) { + throw new AcmeDnsResolutionException($domain.' is not a valid domain'); + } + + return $parse->getRegistrableDomain(); + } +} diff --git a/src/Core/Challenge/Http/FilesystemSolver.php b/src/Core/Challenge/Http/FilesystemSolver.php index 72652f46..47c3a25d 100644 --- a/src/Core/Challenge/Http/FilesystemSolver.php +++ b/src/Core/Challenge/Http/FilesystemSolver.php @@ -88,7 +88,6 @@ public function solve(AuthorizationChallenge $authorizationChallenge) public function cleanup(AuthorizationChallenge $authorizationChallenge) { $checkPath = $this->extractor->getCheckPath($authorizationChallenge); - - $this->filesystem->delete($checkPath); + //$this->filesystem->delete($checkPath); } } diff --git a/src/Core/Challenge/Http/HttpDataExtractor.php b/src/Core/Challenge/Http/HttpDataExtractor.php index 562da7d8..4cf7e31a 100644 --- a/src/Core/Challenge/Http/HttpDataExtractor.php +++ b/src/Core/Challenge/Http/HttpDataExtractor.php @@ -31,7 +31,7 @@ public function getCheckUrl(AuthorizationChallenge $authorizationChallenge) { return sprintf( 'http://%s%s', - $authorizationChallenge->getDomain(), + preg_replace('/^\*\./', '', $authorizationChallenge->getDomain()), $this->getCheckPath($authorizationChallenge) ); } @@ -46,7 +46,7 @@ public function getCheckUrl(AuthorizationChallenge $authorizationChallenge) public function getCheckPath(AuthorizationChallenge $authorizationChallenge) { return sprintf( - '/.well-known/acme-challenge/%s', + $authorizationChallenge->getPath() ? ($authorizationChallenge->getPath() . '%s') : '/.well-known/acme-challenge/%s', $authorizationChallenge->getToken() ); } diff --git a/src/Core/Filesystem/Adapter/FlysystemAdapter.php b/src/Core/Filesystem/Adapter/FlysystemAdapter.php index a61cf0b0..f55c855d 100644 --- a/src/Core/Filesystem/Adapter/FlysystemAdapter.php +++ b/src/Core/Filesystem/Adapter/FlysystemAdapter.php @@ -39,6 +39,7 @@ public function write($path, $content) public function delete($path) { + return; $isOnRemote = $this->filesystem->has($path); if ($isOnRemote && !$this->filesystem->delete($path)) { throw $this->createRuntimeException($path, 'delete'); diff --git a/src/Core/Http/SecureHttpClient.php b/src/Core/Http/SecureHttpClient.php index 5d4a3a04..28bb92ff 100644 --- a/src/Core/Http/SecureHttpClient.php +++ b/src/Core/Http/SecureHttpClient.php @@ -110,6 +110,26 @@ public function __construct( */ public function signedRequest($method, $endpoint, array $payload = [], $returnJson = true) { + $privateKey = $this->accountKeyPair->getPrivateKey(); + + $alg = $this->getAlg(); + $protected = [ + 'alg' => $alg, + 'jwk' => $this->getJWK(), + 'nonce' => $this->getNonce(), + 'url' => $endpoint, + ]; + list($algorithm, $format) = $this->dataSigner->extractSignOptionFromJWSAlg($alg); + + $protected = $this->base64Encoder->encode(json_encode($protected, JSON_UNESCAPED_SLASHES)); + $payload = $this->base64Encoder->encode(json_encode($payload, JSON_UNESCAPED_SLASHES)); + $signature = $this->base64Encoder->encode($this->dataSigner->signData($protected.'.'.$payload, $privateKey, $algorithm, $format)); + + $payload = [ + 'protected' => $protected, + 'payload' => $payload, + 'signature' => $signature, + ]; @trigger_error('The method signedRequest is deprecated since version 1.1 and will be removed in 2.0. use methods request, signKidPayload instead', E_USER_DEPRECATED); return $this->request($method, $endpoint, $this->signJwkPayload($endpoint, $payload), $returnJson); diff --git a/src/Core/Protocol/AuthorizationChallenge.php b/src/Core/Protocol/AuthorizationChallenge.php index 4a30decd..d40ce686 100644 --- a/src/Core/Protocol/AuthorizationChallenge.php +++ b/src/Core/Protocol/AuthorizationChallenge.php @@ -11,6 +11,9 @@ namespace AcmePhp\Core\Protocol; +use Pdp\Cache; +use Pdp\Manager; +use Pdp\CurlHttpClient; use Webmozart\Assert\Assert; /** @@ -50,6 +53,26 @@ class AuthorizationChallenge */ private $payload; + /** + * @var string|null + */ + private $path; + + /** + * @var string|null + */ + private $verifyurl; + + /** + * @var string|null + */ + private $filecontent; + + /** + * @var string|null + */ + private $fqdn; + /** * @param string $domain * @param string $status @@ -57,8 +80,12 @@ class AuthorizationChallenge * @param string $url * @param string $token * @param string $payload + * @param string $path + * @param string $verifyurl + * @param string $filecontent + * @param string $fqdn */ - public function __construct($domain, $status, $type, $url, $token, $payload) + public function __construct($domain, $status, $type, $url, $token, $payload, $path = null, $verifyurl = null, $filecontent = null, $fqdn = null) { Assert::stringNotEmpty($domain, 'Challenge::$domain expected a non-empty string. Got: %s'); Assert::stringNotEmpty($status, 'Challenge::$status expected a non-empty string. Got: %s'); @@ -73,6 +100,10 @@ public function __construct($domain, $status, $type, $url, $token, $payload) $this->url = $url; $this->token = $token; $this->payload = $payload; + $this->path = $path; + $this->verifyurl = $verifyurl; + $this->filecontent = $filecontent; + $this->fqdn = $fqdn; } /** @@ -87,6 +118,9 @@ public function toArray() 'url' => $this->getUrl(), 'token' => $this->getToken(), 'payload' => $this->getPayload(), + 'path' => $this->getPath(), + 'verifyurl' => $this->getVerifyurl(), + 'filecontent' => $this->getFilecontent(), ]; } @@ -103,7 +137,10 @@ public static function fromArray(array $data) $data['type'], $data['url'], $data['token'], - $data['payload'] + $data['payload'], + isset($data['path']) ? $data['path'] : null, + isset($data['verifyurl']) ? $data['verifyurl'] : null, + isset($data['filecontent']) ? $data['filecontent'] : null ); } @@ -170,4 +207,36 @@ public function getPayload() { return $this->payload; } + + /** + * @return string|null + */ + public function getPath() + { + return $this->path; + } + + /** + * @return string|null + */ + public function getVerifyurl() + { + return $this->verifyurl; + } + + /** + * @return string|null + */ + public function getFilecontent() + { + return $this->filecontent; + } + + /** + * @return string|null + */ + public function getFqdn() + { + return $this->fqdn; + } } diff --git a/src/Core/Protocol/ResourcesDirectory.php b/src/Core/Protocol/ResourcesDirectory.php index 017616cb..79afadee 100644 --- a/src/Core/Protocol/ResourcesDirectory.php +++ b/src/Core/Protocol/ResourcesDirectory.php @@ -24,6 +24,7 @@ class ResourcesDirectory const NEW_ORDER = 'newOrder'; const NEW_NONCE = 'newNonce'; const REVOKE_CERT = 'revokeCert'; + const CSR_EAGER = 'csrEager'; /** * @var array @@ -48,6 +49,7 @@ public static function getResourcesNames() self::NEW_ORDER, self::NEW_NONCE, self::REVOKE_CERT, + self::CSR_EAGER, ]; } @@ -68,4 +70,18 @@ public function getResourceUrl($resource) return isset($this->serverResources[$resource]) ? $this->serverResources[$resource] : null; } + + /** + * Is this server requires CSR Eager + * + * @return boolean + */ + public function isCsrEager() + { + if (!isset($this->serverResources['meta']) || !isset($this->serverResources['meta'][self::CSR_EAGER])) { + return false; + } + + return $this->serverResources['meta'][self::CSR_EAGER]; + } } diff --git a/src/Ssl/CertificateResponse.php b/src/Ssl/CertificateResponse.php index fe48a7fa..1eff4ba6 100644 --- a/src/Ssl/CertificateResponse.php +++ b/src/Ssl/CertificateResponse.php @@ -25,11 +25,11 @@ class CertificateResponse private $certificate; /** - * @param CertificateRequest $certificateRequest + * @param CertificateRequest|null $certificateRequest * @param Certificate $certificate */ public function __construct( - CertificateRequest $certificateRequest, + $certificateRequest, Certificate $certificate ) { $this->certificateRequest = $certificateRequest; @@ -37,7 +37,7 @@ public function __construct( } /** - * @return CertificateRequest + * @return CertificateRequest|null */ public function getCertificateRequest() { diff --git a/src/Ssl/DistinguishedName.php b/src/Ssl/DistinguishedName.php index 1eb8ca6e..e4888e7c 100644 --- a/src/Ssl/DistinguishedName.php +++ b/src/Ssl/DistinguishedName.php @@ -64,17 +64,17 @@ public function __construct( $emailAddress = null, array $subjectAlternativeNames = [] ) { - Assert::stringNotEmpty($commonName, __CLASS__.'::$commonName expected a non empty string. Got: %s'); - Assert::nullOrString($countryName, __CLASS__.'::$countryName expected a string. Got: %s'); - Assert::nullOrString($stateOrProvinceName, __CLASS__.'::$stateOrProvinceName expected a string. Got: %s'); - Assert::nullOrString($localityName, __CLASS__.'::$localityName expected a string. Got: %s'); - Assert::nullOrString($organizationName, __CLASS__.'::$organizationName expected a string. Got: %s'); - Assert::nullOrString($organizationalUnitName, __CLASS__.'::$organizationalUnitName expected a string. Got: %s'); - Assert::nullOrString($emailAddress, __CLASS__.'::$emailAddress expected a string. Got: %s'); - Assert::allStringNotEmpty( - $subjectAlternativeNames, - __CLASS__.'::$subjectAlternativeNames expected an array of non empty string. Got: %s' - ); + //Assert::stringNotEmpty($commonName, __CLASS__.'::$commonName expected a non empty string. Got: %s'); + //Assert::nullOrString($countryName, __CLASS__.'::$countryName expected a string. Got: %s'); + //Assert::nullOrString($stateOrProvinceName, __CLASS__.'::$stateOrProvinceName expected a string. Got: %s'); + //Assert::nullOrString($localityName, __CLASS__.'::$localityName expected a string. Got: %s'); + //Assert::nullOrString($organizationName, __CLASS__.'::$organizationName expected a string. Got: %s'); + //Assert::nullOrString($organizationalUnitName, __CLASS__.'::$organizationalUnitName expected a string. Got: %s'); + //Assert::nullOrString($emailAddress, __CLASS__.'::$emailAddress expected a string. Got: %s'); + //Assert::allStringNotEmpty( + //$subjectAlternativeNames, + //__CLASS__.'::$subjectAlternativeNames expected an array of non empty string. Got: %s' + //); $this->commonName = $commonName; $this->countryName = $countryName; diff --git a/src/Ssl/Exception/DataCheckingSignException.php b/src/Ssl/Exception/DataCheckingSignException.php new file mode 100644 index 00000000..9dc5e4af --- /dev/null +++ b/src/Ssl/Exception/DataCheckingSignException.php @@ -0,0 +1,19 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace AcmePhp\Ssl\Exception; + +/** + * @author Xiaohui Lam + */ +class DataCheckingSignException extends SigningException +{ +} diff --git a/src/Ssl/Signer/DataSigner.php b/src/Ssl/Signer/DataSigner.php index dbce6aca..67f4ec18 100644 --- a/src/Ssl/Signer/DataSigner.php +++ b/src/Ssl/Signer/DataSigner.php @@ -14,6 +14,10 @@ use AcmePhp\Ssl\Exception\DataSigningException; use AcmePhp\Ssl\PrivateKey; use Webmozart\Assert\Assert; +use AcmePhp\Ssl\PublicKey; +use AcmePhp\Ssl\Exception\DataCheckingSignException; +use AcmePhp\Core\Exception\AcmeCoreClientException; +use Jose\Component\Core\Util\ECSignature; /** * Provide tools to sign data using a private key. @@ -51,86 +55,115 @@ public function signData($data, PrivateKey $privateKey, $algorithm = OPENSSL_ALG switch ($format) { case self::FORMAT_DER: return $signature; + break; + case self::FORMAT_ECDSA: switch ($algorithm) { case OPENSSL_ALGO_SHA256: - return $this->DERtoECDSA($signature, 64); + return ECSignature::fromAsn1($signature, 64); + break; + case OPENSSL_ALGO_SHA384: - return $this->DERtoECDSA($signature, 96); + return ECSignature::fromAsn1($signature, 96); + break; + case OPENSSL_ALGO_SHA512: - return $this->DERtoECDSA($signature, 132); + return ECSignature::fromAsn1($signature, 132); + break; } throw new DataSigningException('Unable to generate a ECDSA signature with the given algorithm'); + break; + default: throw new DataSigningException('The given format does exists'); + break; } } /** - * Convert a DER signature into ECDSA. - * - * The code is a copy/paste from another lib (web-token/jwt-core) which is not compatible with php <= 7.0 + * Check sign * - * @see https://github.com/web-token/jwt-core/blob/master/Util/ECSignature.php + * @param string $signature + * @param string $data + * @param PublicKey $publicKey + * @param int $algorithm + * @param string $format + * @return void */ - private function DERtoECDSA($der, $partLength) + public function checkSign($signature, $data, PublicKey $publicKey, $algorithm = OPENSSL_ALGO_SHA256, $format = self::FORMAT_DER) { - $hex = unpack('H*', $der)[1]; - if ('30' !== mb_substr($hex, 0, 2, '8bit')) { // SEQUENCE - throw new DataSigningException('Invalid signature provided'); - } - if ('81' === mb_substr($hex, 2, 2, '8bit')) { // LENGTH > 128 - $hex = mb_substr($hex, 6, null, '8bit'); - } else { - $hex = mb_substr($hex, 4, null, '8bit'); - } - if ('02' !== mb_substr($hex, 0, 2, '8bit')) { // INTEGER - throw new DataSigningException('Invalid signature provided'); - } + Assert::oneOf($format, [self::FORMAT_ECDSA, self::FORMAT_DER], 'The format %s to sign request does not exists. Available format: %s'); - $Rl = hexdec(mb_substr($hex, 2, 2, '8bit')); - $R = $this->retrievePositiveInteger(mb_substr($hex, 4, $Rl * 2, '8bit')); - $R = str_pad($R, $partLength, '0', STR_PAD_LEFT); + $resource = $publicKey->getResource(); + + switch ($format) { + case self::FORMAT_DER: + $signature = $signature; + break; + + case self::FORMAT_ECDSA: + switch ($algorithm) { + case OPENSSL_ALGO_SHA256: + $signature = ECSignature::toAsn1($signature, 64); + break; + + case OPENSSL_ALGO_SHA384: + $signature = ECSignature::toAsn1($signature, 96); + break; - $hex = mb_substr($hex, 4 + $Rl * 2, null, '8bit'); - if ('02' !== mb_substr($hex, 0, 2, '8bit')) { // INTEGER - throw new DataSigningException('Invalid signature provided'); + case OPENSSL_ALGO_SHA512: + $signature = ECSignature::toAsn1($signature, 132); + break; + + default: + throw new DataSigningException('Unable to generate a ECDSA signature with the given algorithm'); + break; + } + break; + + default: + throw new DataSigningException('The given format does exists'); + break; + } + + if (1 != openssl_verify($data, $signature, $resource, $algorithm)) { + throw new DataCheckingSignException( + sprintf('OpenSSL data checking sign failed with error: %s', openssl_error_string()) + ); } - $Sl = hexdec(mb_substr($hex, 2, 2, '8bit')); - $S = $this->retrievePositiveInteger(mb_substr($hex, 4, $Sl * 2, '8bit')); - $S = str_pad($S, $partLength, '0', STR_PAD_LEFT); - return pack('H*', $R.$S); + openssl_free_key($resource); } /** - * The code is a copy/paste from another lib (web-token/jwt-core) which is not compatible with php <= 7.0. + * Extract Sign Option From Jws Alg * - * @see https://github.com/web-token/jwt-core/blob/master/Util/ECSignature.php + * @param string $alg + * @return array */ - private function preparePositiveInteger($data) + public function extractSignOptionFromJWSAlg($alg) { - if (mb_substr($data, 0, 2, '8bit') > '7f') { - return '00'.$data; + if (!preg_match('/^([A-Z]+)(\d+)$/', $alg, $match)) { + throw new AcmeCoreClientException(sprintf('The given "%s" algorithm is not supported', $alg)); } - while ('00' === mb_substr($data, 0, 2, '8bit') && mb_substr($data, 2, 2, '8bit') <= '7f') { - $data = mb_substr($data, 2, null, '8bit'); + + if (!\defined('OPENSSL_ALGO_SHA' . $match[2])) { + throw new AcmeCoreClientException(sprintf('The given "%s" algorithm is not supported', $alg)); } - return $data; - } + $algorithm = \constant('OPENSSL_ALGO_SHA' . $match[2]); - /** - * The code is a copy/paste from another lib (web-token/jwt-core) which is not compatible with php <= 7.0. - * - * @see https://github.com/web-token/jwt-core/blob/master/Util/ECSignature.php - */ - private function retrievePositiveInteger($data) - { - while ('00' === mb_substr($data, 0, 2, '8bit') && mb_substr($data, 2, 2, '8bit') > '7f') { - $data = mb_substr($data, 2, null, '8bit'); + switch ($match[1]) { + case 'RS': + $format = static::FORMAT_DER; + break; + case 'ES': + $format = static::FORMAT_ECDSA; + break; + default: + throw new AcmeCoreClientException(sprintf('The given "%s" algorithm is not supported', $alg)); } - return $data; + return [$algorithm, $format]; } }