diff --git a/README.md b/README.md index 7e631d3..b0224f1 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,26 +99,62 @@ 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::VALIDATON_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). + ### Get the certificate Now to know if we can request a certificate for the order, test if the order is ready as follows: diff --git a/src/Client.php b/src/Client.php index 27d1996..378c5e7 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 */ @@ -262,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); } } @@ -289,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'); @@ -389,21 +398,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) { } @@ -412,6 +417,56 @@ 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; + } + } + } + if ($maxAttempts > 1) { + sleep(ceil(45 / $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 7760a1c..44acfe6 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 { @@ -93,6 +94,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; + } + /** * Return File object for the given challenge * @return File|bool @@ -105,4 +120,22 @@ class Authorization } return false; } + + /** + * Returns the DNS record object + * + * @param Challenge $challenge + * @return Record|bool + */ + public function getTxtRecord() + { + $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 70b5ed8..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);