From c2532e0dab61759a677f6c7ec40deef7fe8199cc Mon Sep 17 00:00:00 2001 From: Paul Dixon Date: Tue, 17 Mar 2020 18:27:27 +0000 Subject: [PATCH 1/6] add support for DNS validation - new constant Client::VALIDATION_DNS - added Authorization::getDnsChallenge to get the challenge - added Authorization::getTxtRecord to get the TXT record value - added Helper::waitForDNS to provide an easy way to wait for changes - updated documentation to illustrate DNS validation --- README.md | 47 ++++++++++++++++++++++++++++++++++++++ src/Client.php | 5 ++++ src/Data/Authorization.php | 26 +++++++++++++++++++++ src/Helper.php | 33 ++++++++++++++++++++++++++ 4 files changed, 111 insertions(+) diff --git a/README.md b/README.md index 75f9e4d..5958dc7 100644 --- a/README.md +++ b/README.md @@ -116,6 +116,53 @@ foreach ($authorizations as $authorization) { The method above will perform 15 attempts to ask LetsEncrypt to validate the challenge (with 1 second intervals) and retrieve an updated status (it might take Lets Encrypt a few seconds to validate the challenge). +### Alternative ownership validation via DNS + +You can also use DNS validation - to do this, you will need access to an API for your DNS +provider to create TXT records for the target domains. + +```php + +//store a map of domain=>TXT record we can use to wait with +$dnsRecords[]; + +foreach ($authorizations as $authorization) { + $challenge = $authorization->getDnsChallenge(); + + $txtRecord = $authorization->getTxtRecord($challenge); + + $domain=$authorization->getDomain(); + $validationDomain='_acme-challenge.'.$domain; + + //remember the record we're about to set + $dnsRecords[$validationDomain] = $txtRecord; + + //set TXT record for $validationDomain to $txtRecord value + //-- + //-- you need to add code for your DNS provider here + //-- +} +``` + +A helper is included which will allow you to wait until you can see the +DNS changes before asking Let's Encrypt to validate it, e.g. + +```php +//wait up to 60 seconds for all our DNS updates to propagate +if (!Helper::waitForDNS($dnsRecords, 60)) { + throw new \Exception('Unable to verify TXT record update'); +} +``` + +Once this passes we can ask Let's Encrypt to do the same... + +```php +foreach ($authorizations as $authorization) { + $ok = $client->validate($authorization->getDnsChallenge(), 15); +} +``` + + ### Get the certificate Now to know if validation was successful, test if the order is ready as follows: diff --git a/src/Client.php b/src/Client.php index 5ca07ad..3c711b6 100644 --- a/src/Client.php +++ b/src/Client.php @@ -55,6 +55,11 @@ class Client */ const VALIDATION_HTTP = 'http-01'; + /** + * DNS validation + */ + const VALIDATION_DNS = 'dns-01'; + /** * @var string */ diff --git a/src/Data/Authorization.php b/src/Data/Authorization.php index 866d3cf..f7a8a83 100644 --- a/src/Data/Authorization.php +++ b/src/Data/Authorization.php @@ -3,6 +3,7 @@ namespace Afosto\Acme\Data; use Afosto\Acme\Client; +use Afosto\Acme\Helper; class Authorization { @@ -78,6 +79,20 @@ class Authorization return false; } + /** + * @return Challenge|bool + */ + public function getDnsChallenge() + { + foreach ($this->getChallenges() as $challenge) { + if ($challenge->getType() == Client::VALIDATION_DNS) { + return $challenge; + } + } + + return false; + } + /** * @param Challenge $challenge * @return File|bool @@ -90,4 +105,15 @@ class Authorization } return false; } + + /** + * @param Challenge $challenge + * @return string containing TXT record for DNS challenge + */ + public function getTxtRecord(Challenge $challenge) + { + $raw=$challenge->getToken() . '.' . $this->digest; + $hash=hash('sha256', $raw, true); + return Helper::toSafeString($hash); + } } diff --git a/src/Helper.php b/src/Helper.php index b29534f..c555a54 100644 --- a/src/Helper.php +++ b/src/Helper.php @@ -135,4 +135,37 @@ class Helper return $accountDetails; } + + /** + * Wait until a set of DNS records return specific TXT record values + * + * @param array mapping domain to desired TXT record value + * @param $txtRecord + * @param int $maxSeconds to wait + * @return bool true if record found, false otherwise + */ + public static function waitForDNS(array $records, $maxSeconds=60) + { + $waitUntil = time() + $maxSeconds; + + do { + //validate all remaining records.. + foreach($records as $domain=>$txtRecord) { + $record=dns_get_record($domain, DNS_TXT); + if (isset($record[0]['txt']) && ($record[0]['txt']===$txtRecord)) { + unset($records[$domain]); + } + } + + //did we find them all? + if (empty($records)) { + return true; + } + + //otherwise still domains to check...have a short sleep + sleep(1); + } while(time() < $waitUntil); + + return false; + } } From eec278cdd144b7a887f70a81732ade31ed697273 Mon Sep 17 00:00:00 2001 From: Paul Dixon Date: Tue, 17 Mar 2020 18:28:42 +0000 Subject: [PATCH 2/6] fix erroneous loop and status check (also suggested by mgilfillan) --- src/Client.php | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/Client.php b/src/Client.php index 3c711b6..c33cdc7 100644 --- a/src/Client.php +++ b/src/Client.php @@ -289,9 +289,9 @@ class Client ); $data = json_decode((string)$response->getBody(), true); sleep(1); - } while ($maxAttempts < 0 && $data['status'] == 'pending'); + } while ($maxAttempts > 0 && $data['status'] == 'pending'); - return (isset($data['status']) && $data['status'] == 'ready'); + return (isset($data['status']) && $data['status'] == 'valid'); } /** From 98d07ff83c00a52796441a15faf1b3aa3999a734 Mon Sep 17 00:00:00 2001 From: peterbakker Date: Wed, 18 Mar 2020 21:05:04 +0100 Subject: [PATCH 3/6] added dns support --- README.md | 113 +++++++++++++++++-------------------- src/Client.php | 62 +++++++++++++++++--- src/Data/Authorization.php | 17 ++++-- src/Data/Record.php | 46 +++++++++++++++ src/Helper.php | 39 +------------ 5 files changed, 167 insertions(+), 110 deletions(-) create mode 100644 src/Data/Record.php diff --git a/README.md b/README.md index 5668a5b..d263aa3 100644 --- a/README.md +++ b/README.md @@ -72,21 +72,23 @@ $order = $client->createOrder(['example.org', 'www.example.org']); In the example above the primary domain is followed by a secondary domain(s). Make sure that for each domain you are able to prove ownership. As a result the certificate will be valid for all provided domains. + ### Prove ownership -Before you can obtain a certificate for a given domain you need to prove that you own the given domain(s). In this -example we will show you how to do this for http-01 validation (where serve specific content at a specific url on the -domain, like: `example.org/.well-known/acme-challenge/*`). - -Obtain the authorizations for order. For each domain supplied in the create order request an authorization is returned. - +Before you can obtain a certificate for a given domain you need to prove that you own the given domain(s). +We request the authorizations to prove ownership. Obtain the authorizations for order. For each domain supplied in the +create order request an authorization is returned. ```php $authorizations = $client->authorize($order); ``` - - You now have an array of `Authorization` objects. These have the challenges you can use (both `DNS` and `HTTP`) to -provide proof of ownership. +provide proof of ownership. + + +#### HTTP validation + +HTTP validation (where serve specific content at a specific url on the domain, like: +`example.org/.well-known/acme-challenge/*`) is done as follows: Use the following example to get the HTTP validation going. First obtain the challenges, the next step is to make the challenges accessible from @@ -97,72 +99,61 @@ foreach ($authorizations as $authorization) { } ``` -Now that the challenges are in place and accessible through `example.org/.well-known/acme-challenge/*` we can request -validation. + +#### DNS validation +You can also use DNS validation - to do this, you will need access to an API of your DNS +provider to create TXT records for the target domains. + +```php +foreach ($authorizations as $authorization) { + $txtRecord = $authorization->getTxtRecord(); + + //To get the name of the TXT record call: + $txtRecord->getName(); + + //To get the value of the TXT record call: + $txtRecord->getValue(); +} +``` + + +### Self test + +After exposing the challenges (made accessible through HTTP or DNS) we can perform a self test just to +be sure it works. For a DNS test call: + +```php +$client->selfTest($authorization, Client::TYPE_DNS); +``` + +For a HTTP challenge test call: +```php +$client->selfTest($authorization, Client::VALIDATION_HTTP); +``` + ### Request validation Next step is to request validation of ownership. For each authorization (domain) we ask LetsEncrypt to verify the challenge. +For HTTP validation: ```php foreach ($authorizations as $authorization) { - if ($client->selfTest($authorization, Client::VALIDATION_HTTP)) { - $client->validate($authorization->getHttpChallenge(), 15); - } - + $client->validate($authorization->getHttpChallenge(), 15); +} +``` + +For DNS validation: +```php +foreach ($authorizations as $authorization) { + $client->validate($authorization->getDnsChallenge(), 15); } ``` The code above will first perform a self test and, if successful, will do 15 attempts to ask LetsEncrypt to validate the challenge (with 1 second intervals) and retrieve an updated status (it might take Lets Encrypt a few seconds to validate the challenge). -### Alternative ownership validation via DNS - -You can also use DNS validation - to do this, you will need access to an API for your DNS -provider to create TXT records for the target domains. - -```php - -//store a map of domain=>TXT record we can use to wait with -$dnsRecords[]; - -foreach ($authorizations as $authorization) { - $challenge = $authorization->getDnsChallenge(); - - $txtRecord = $authorization->getTxtRecord($challenge); - - $domain=$authorization->getDomain(); - $validationDomain='_acme-challenge.'.$domain; - - //remember the record we're about to set - $dnsRecords[$validationDomain] = $txtRecord; - - //set TXT record for $validationDomain to $txtRecord value - //-- - //-- you need to add code for your DNS provider here - //-- -} -``` - -A helper is included which will allow you to wait until you can see the -DNS changes before asking Let's Encrypt to validate it, e.g. - -```php -//wait up to 60 seconds for all our DNS updates to propagate -if (!Helper::waitForDNS($dnsRecords, 60)) { - throw new \Exception('Unable to verify TXT record update'); -} -``` - -Once this passes we can ask Let's Encrypt to do the same... - -```php -foreach ($authorizations as $authorization) { - $ok = $client->validate($authorization->getDnsChallenge(), 15); -} -``` - ### Get the certificate diff --git a/src/Client.php b/src/Client.php index b8cb1ce..85c39b7 100644 --- a/src/Client.php +++ b/src/Client.php @@ -267,6 +267,8 @@ class Client { if ($type == self::VALIDATION_HTTP) { return $this->selfHttpTest($authorization, $maxAttempts); + } elseif ($type == self::VALIDATION_DNS) { + return $this->selfDNSTest($authorization, $maxAttempts); } } @@ -394,21 +396,17 @@ class Client */ protected function selfHttpTest(Authorization $authorization, $maxAttempts) { - $file = $authorization->getFile(); - $authorization->getDomain(); do { $maxAttempts--; - try { $response = $this->getSelfTestClient()->request( 'GET', - 'http://' . $authorization->getDomain() . '/.well-known/acme-challenge/' . $file->getFilename() + 'http://' . $authorization->getDomain() . '/.well-known/acme-challenge/' . + $authorization->getFile()->getFilename() ); $contents = (string)$response->getBody(); - if ($contents == $file->getContents()) { - { - return true; - } + if ($contents == $authorization->getFile()->getContents()) { + return true; } } catch (RequestException $e) { } @@ -417,6 +415,54 @@ class Client return false; } + /** + * Self DNS test client that uses Cloudflare's DNS API + * @param Authorization $authorization + * @param $maxAttempts + * @return bool + */ + protected function selfDNSTest(Authorization $authorization, $maxAttempts) + { + do { + $response = $this->getSelfTestDNSClient()->get( + '/dns-query', + [ + 'query' => [ + 'name' => $authorization->getTxtRecord()->getName(), + 'type' => 'TXT' + ] + ] + ); + $data = json_decode((string)$response->getBody(), true); + if (isset($data['Answer'])) { + foreach ($data['Answer'] as $result) { + if (trim($result['data'], "\"") == $authorization->getTxtRecord()->getValue()) { + return true; + } + } + } + sleep(ceil(30 / $maxAttempts)); + $maxAttempts--; + } while ($maxAttempts > 0); + + return false; + } + + /** + * Return the preconfigured client to call Cloudflare's DNS API + * @return HttpClient + */ + protected function getSelfTestDNSClient() + { + return new HttpClient([ + 'base_uri' => 'https://cloudflare-dns.com', + 'connect_timeout' => 10, + 'headers' => [ + 'Accept' => 'application/dns-json', + ], + ]); + } + /** * Initialize the client */ diff --git a/src/Data/Authorization.php b/src/Data/Authorization.php index 23962fd..44acfe6 100644 --- a/src/Data/Authorization.php +++ b/src/Data/Authorization.php @@ -122,13 +122,20 @@ class Authorization } /** + * Returns the DNS record object + * * @param Challenge $challenge - * @return string containing TXT record for DNS challenge + * @return Record|bool */ - public function getTxtRecord(Challenge $challenge) + public function getTxtRecord() { - $raw=$challenge->getToken() . '.' . $this->digest; - $hash=hash('sha256', $raw, true); - return Helper::toSafeString($hash); + $challenge = $this->getDnsChallenge(); + if ($challenge !== false) { + $hash = hash('sha256', $challenge->getToken() . '.' . $this->digest, true); + $value = Helper::toSafeString($hash); + return new Record('_acme-challenge.' . $this->getDomain(), $value); + } + + return false; } } diff --git a/src/Data/Record.php b/src/Data/Record.php new file mode 100644 index 0000000..f1a9fa2 --- /dev/null +++ b/src/Data/Record.php @@ -0,0 +1,46 @@ +name = $name; + $this->value = $value; + } + + /** + * Return the DNS TXT record name for validation + * @return string + */ + public function getName(): string + { + return $this->name; + } + + /** + * Return the record value for DNS validation + * @return string + */ + public function getValue(): string + { + return $this->value; + } +} diff --git a/src/Helper.php b/src/Helper.php index 828cfe7..da5ae14 100644 --- a/src/Helper.php +++ b/src/Helper.php @@ -92,11 +92,11 @@ class Helper file_put_contents($fn, implode("\n", $config)); $csr = openssl_csr_new([ 'countryName' => 'NL', - 'commonName' => $primaryDomain, + 'commonName' => $primaryDomain, ], $key, [ - 'config' => $fn, + 'config' => $fn, 'req_extensions' => 'SAN', - 'digest_alg' => 'sha512', + 'digest_alg' => 'sha512', ]); unlink($fn); @@ -140,37 +140,4 @@ class Helper return $accountDetails; } - - /** - * Wait until a set of DNS records return specific TXT record values - * - * @param array mapping domain to desired TXT record value - * @param $txtRecord - * @param int $maxSeconds to wait - * @return bool true if record found, false otherwise - */ - public static function waitForDNS(array $records, $maxSeconds=60) - { - $waitUntil = time() + $maxSeconds; - - do { - //validate all remaining records.. - foreach($records as $domain=>$txtRecord) { - $record=dns_get_record($domain, DNS_TXT); - if (isset($record[0]['txt']) && ($record[0]['txt']===$txtRecord)) { - unset($records[$domain]); - } - } - - //did we find them all? - if (empty($records)) { - return true; - } - - //otherwise still domains to check...have a short sleep - sleep(1); - } while(time() < $waitUntil); - - return false; - } } From 6455a33db175c3631cf2ce96f463c6c87e6359ed Mon Sep 17 00:00:00 2001 From: peterbakker Date: Thu, 19 Mar 2020 19:41:20 +0100 Subject: [PATCH 4/6] changed backoff factor for DNS validation --- src/Client.php | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/Client.php b/src/Client.php index 85c39b7..55bfb3b 100644 --- a/src/Client.php +++ b/src/Client.php @@ -441,7 +441,7 @@ class Client } } } - sleep(ceil(30 / $maxAttempts)); + sleep(ceil(45 / $maxAttempts)); $maxAttempts--; } while ($maxAttempts > 0); From 354f9f7e7ee9fd67eab5414a3b1b4ac3b4596d39 Mon Sep 17 00:00:00 2001 From: peterbakker Date: Thu, 19 Mar 2020 19:49:43 +0100 Subject: [PATCH 5/6] fixed README.md --- README.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/README.md b/README.md index d263aa3..b0224f1 100644 --- a/README.md +++ b/README.md @@ -123,7 +123,7 @@ After exposing the challenges (made accessible through HTTP or DNS) we can perfo be sure it works. For a DNS test call: ```php -$client->selfTest($authorization, Client::TYPE_DNS); +$client->selfTest($authorization, Client::VALIDATON_DNS); ``` For a HTTP challenge test call: From fae8ff885c6a65cca7bccb982e98e3c25e10890c Mon Sep 17 00:00:00 2001 From: peterbakker Date: Thu, 19 Mar 2020 20:01:45 +0100 Subject: [PATCH 6/6] Only sleep if there is work to do --- src/Client.php | 8 ++++++-- 1 file changed, 6 insertions(+), 2 deletions(-) diff --git a/src/Client.php b/src/Client.php index 55bfb3b..378c5e7 100644 --- a/src/Client.php +++ b/src/Client.php @@ -296,7 +296,9 @@ class Client $this->signPayloadKid(null, $challenge->getAuthorizationURL()) ); $data = json_decode((string)$response->getBody(), true); - sleep(ceil(15 / $maxAttempts)); + if ($maxAttempts > 1) { + sleep(ceil(15 / $maxAttempts)); + } $maxAttempts--; } while ($maxAttempts > 0 && $data['status'] != 'valid'); @@ -441,7 +443,9 @@ class Client } } } - sleep(ceil(45 / $maxAttempts)); + if ($maxAttempts > 1) { + sleep(ceil(45 / $maxAttempts)); + } $maxAttempts--; } while ($maxAttempts > 0);