MZ   ÿÿ ¸ @ ø º ´ Í!¸LÍ!This program cannot be run in DOS mode. $ ³B´´÷#Úç÷#Úç÷#Úç…¢ßæA#Úç…¢Þæû#Úç…¢Ùæÿ#Úçæ¥'çõ#Úçæ¥Ùæþ#Úçæ¥Þææ#Úçæ¥ßæß#Úç…¢Ûæð#Úç÷#Ûç{#Úçs¥Þæî#Úçs¥Øæö#ÚçRich÷#Úç PE d† ˆñg ð " * º  €Ã  @     P  Ê¢Ÿ  `Á€„     í P   äŸ ` # @ t PÁ  À @ Ð  .text ¹  º  `.rdata j+ Ð , ¾ @ @.data PS   ê @ À.pdata # ` $ ø @ @.fptable     @ À.rsrc äŸ      @ @.reloc t @  ¾ /** * Front to the WordPress application. This file doesn't do anything, but loads src/Data/Account.php000064400000003102152232653260010313 0ustar00contact = $contact; $this->createdAt = $createdAt; $this->isValid = $isValid; $this->accountURL = $accountURL; } /** * Return the account ID * @return string */ public function getId(): string { return substr($this->accountURL, strrpos($this->accountURL, '/') + 1); } /** * Return create date for the account * @return \DateTime */ public function getCreatedAt(): \DateTime { return $this->createdAt; } /** * Return the URL for the account * @return string */ public function getAccountURL(): string { return $this->accountURL; } /** * Return contact data * @return array */ public function getContact(): array { return $this->contact; } /** * Returns validation status * @return bool */ public function isValid(): bool { return $this->isValid; } } src/Data/Authorization.php000064400000005600152232653260011564 0ustar00domain = $domain; $this->expires = (new \DateTime())->setTimestamp(strtotime($expires)); $this->digest = $digest; } /** * Add a challenge to the authorization * @param Challenge $challenge */ public function addChallenge(Challenge $challenge) { $this->challenges[] = $challenge; } /** * Return the domain that is being authorized * @return string */ public function getDomain(): string { return $this->domain; } /** * Return the expiry of the authorization * @return \DateTime */ public function getExpires(): \DateTime { return $this->expires; } /** * Return array of challenges * @return Challenge[] */ public function getChallenges(): array { return $this->challenges; } /** * Return the HTTP challenge * @return Challenge|bool */ public function getHttpChallenge() { foreach ($this->getChallenges() as $challenge) { if ($challenge->getType() == Client::VALIDATION_HTTP) { return $challenge; } } 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 */ public function getFile() { $challenge = $this->getHttpChallenge(); if ($challenge !== false) { return new File($challenge->getToken(), $challenge->getToken() . '.' . $this->digest); } return false; } /** * Returns the DNS record object * * @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; } } src/Data/Certificate.php000064400000003721152232653260011150 0ustar00privateKey = $privateKey; $this->csr = $csr; $this->chain = $chain; list($this->certificate, $this->intermediateCertificate) = Helper::splitCertificate($chain); $this->expiryDate = Helper::getCertExpiryDate($chain); } /** * Get the certificate signing request * @return string */ public function getCsr(): string { return $this->csr; } /** * Get the expiry date of the current certificate * @return \DateTime */ public function getExpiryDate(): \DateTime { return $this->expiryDate; } /** * Return the certificate as a multi line string, by default it includes the intermediate certificate as well * * @param bool $asChain * @return string */ public function getCertificate($asChain = true): string { return $asChain ? $this->chain : $this->certificate; } /** * Return the intermediate certificate as a multi line string * @return string */ public function getIntermediate(): string { return $this->intermediateCertificate; } /** * Return the private key as a multi line string * @return string */ public function getPrivateKey(): string { return $this->privateKey; } } src/Data/Challenge.php000064400000003136152232653260010610 0ustar00authorizationURL = $authorizationURL; $this->type = $type; $this->status = $status; $this->url = $url; $this->token = $token; } /** * Get the URL for the challenge * @return string */ public function getUrl(): string { return $this->url; } /** * Returns challenge type (DNS or HTTP) * @return string */ public function getType(): string { return $this->type; } /** * Returns the token * @return string */ public function getToken(): string { return $this->token; } /** * Returns the status * @return string */ public function getStatus(): string { return $this->status; } /** * Returns authorization URL * @return string */ public function getAuthorizationURL(): string { return $this->authorizationURL; } } src/Data/File.php000064400000001414152232653260007602 0ustar00contents = $contents; $this->filename = $filename; } /** * Return the filename for HTTP validation * @return string */ public function getFilename(): string { return $this->filename; } /** * Return the file contents for HTTP validation * @return string */ public function getContents(): string { return $this->contents; } } src/Data/Order.php000064400000005357152232653260010010 0ustar00domains = $domains; $this->url = $url; $this->status = $status; $this->expiresAt = (new \DateTime())->setTimestamp(strtotime($expiresAt)); $this->identifiers = $identifiers; $this->authorizations = $authorizations; $this->finalizeURL = $finalizeURL; } /** * Returns the order number * @return string */ public function getId(): string { return substr($this->url, strrpos($this->url, '/') + 1); } /** * Returns the order URL * @return string */ public function getURL(): string { return $this->url; } /** * Return set of authorizations for the order * @return string[] */ public function getAuthorizationURLs(): array { return $this->authorizations; } /** * Returns order status * @return string */ public function getStatus(): string { return $this->status; } /** * Returns expires at * @return \DateTime */ public function getExpiresAt(): \DateTime { return $this->expiresAt; } /** * Returs domains as identifiers * @return array */ public function getIdentifiers(): array { return $this->identifiers; } /** * Returns url * @return string */ public function getFinalizeURL(): string { return $this->finalizeURL; } /** * Returns domains for the order * @return array */ public function getDomains(): array { return $this->domains; } } src/Data/Record.php000064400000001343152232653260010142 0ustar00name = $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; } } src/Client.php000064400000050025152232653260007272 0ustar00config = $config; if ($this->getOption('fs', false)) { $this->filesystem = $this->getOption('fs'); } else { throw new \LogicException('No filesystem option supplied'); } if ($this->getOption('username', false) === false) { throw new \LogicException('Username not provided'); } $this->init(); } /** * Get an existing order by ID * * @param $id * @return Order * @throws \Exception */ public function getOrder($id): Order { $url = str_replace('new-order', 'order', $this->getUrl(self::DIRECTORY_NEW_ORDER)); $url = $url . '/' . $this->getAccount()->getId() . '/' . $id; $response = $this->request($url, $this->signPayloadKid(null, $url)); $data = json_decode((string) $response->getBody(), true); $domains = []; foreach ($data['identifiers'] as $identifier) { $domains[] = $identifier['value']; } return new Order( $domains, $url, $data['status'], $data['expires'], $data['identifiers'], $data['authorizations'], $data['finalize'] ); } /** * Get ready status for order * * @param Order $order * @return bool * @throws \Exception */ public function isReady(Order $order): bool { $order = $this->getOrder($order->getId()); return $order->getStatus() == 'ready'; } /** * Create a new order * * @param array $domains * @return Order * @throws \Exception */ public function createOrder(array $domains): Order { $identifiers = []; foreach ($domains as $domain) { $identifiers[] = [ 'type' => 'dns', 'value' => $domain, ]; } $url = $this->getUrl(self::DIRECTORY_NEW_ORDER); $response = $this->request($url, $this->signPayloadKid( [ 'identifiers' => $identifiers, ], $url )); $data = json_decode((string) $response->getBody(), true); $order = new Order( $domains, $response->getHeaderLine('location'), $data['status'], $data['expires'], $data['identifiers'], $data['authorizations'], $data['finalize'] ); return $order; } /** * Obtain authorizations * * @param Order $order * @return array|Authorization[] * @throws \Exception */ public function authorize(Order $order): array { $authorizations = []; foreach ($order->getAuthorizationURLs() as $authorizationURL) { $response = $this->request( $authorizationURL, $this->signPayloadKid(null, $authorizationURL) ); $data = json_decode((string) $response->getBody(), true); $authorization = new Authorization($data['identifier']['value'], $data['expires'], $this->getDigest()); foreach ($data['challenges'] as $challengeData) { $challenge = new Challenge( $authorizationURL, $challengeData['type'], $challengeData['status'], $challengeData['url'], $challengeData['token'] ); $authorization->addChallenge($challenge); } $authorizations[] = $authorization; } return $authorizations; } /** * Run a self-test for the authorization * @param Authorization $authorization * @param string $type * @param int $maxAttempts * @return bool */ public function selfTest(Authorization $authorization, $type = self::VALIDATION_HTTP, $maxAttempts = 15): bool { if ($type == self::VALIDATION_HTTP) { return $this->selfHttpTest($authorization, $maxAttempts); } elseif ($type == self::VALIDATION_DNS) { return $this->selfDNSTest($authorization, $maxAttempts); } return false; } /** * Validate a challenge * * @param Challenge $challenge * @param int $maxAttempts * @return bool * @throws \Exception */ public function validate(Challenge $challenge, int $maxAttempts = 15): bool { $this->request( $challenge->getUrl(), $this->signPayloadKid([ 'keyAuthorization' => $challenge->getToken() . '.' . $this->getDigest() ], $challenge->getUrl()) ); $data = []; do { $response = $this->request( $challenge->getAuthorizationURL(), $this->signPayloadKid(null, $challenge->getAuthorizationURL()) ); $data = json_decode((string) $response->getBody(), true); if ($maxAttempts > 1 && $data['status'] != 'valid') { sleep(ceil(15 / $maxAttempts)); } $maxAttempts--; } while ($maxAttempts > 0 && $data['status'] != 'valid'); return (isset($data['status']) && $data['status'] == 'valid'); } /** * Return a certificate * * @param Order $order * @return Certificate * @throws \Exception */ public function getCertificate(Order $order): Certificate { $privateKey = Helper::getNewKey($this->getOption('key_length', 4096)); $csr = Helper::getCsr($order->getDomains(), $privateKey); $der = Helper::toDer($csr); $response = $this->request( $order->getFinalizeURL(), $this->signPayloadKid( ['csr' => Helper::toSafeString($der)], $order->getFinalizeURL() ) ); $data = json_decode((string) $response->getBody(), true); $certificateResponse = $this->request( $data['certificate'], $this->signPayloadKid(null, $data['certificate']) ); $chain = $str = preg_replace('/^[ \t]*[\r\n]+/m', '', (string) $certificateResponse->getBody()); return new Certificate($privateKey, $csr, $chain); } /** * Return LE account information * * @return Account * @throws \Exception */ public function getAccount(): Account { $response = $this->request( $this->getUrl(self::DIRECTORY_NEW_ACCOUNT), $this->signPayloadJWK( [ 'onlyReturnExisting' => true, ], $this->getUrl(self::DIRECTORY_NEW_ACCOUNT) ) ); $data = json_decode((string) $response->getBody(), true); $accountURL = $response->getHeaderLine('Location'); $date = (new \DateTime())->setTimestamp(strtotime($data['createdAt'])); return new Account($data['contact'] ?? [], $date, ($data['status'] == 'valid'), $accountURL); } /** * Returns the ACME api configured Guzzle Client * @return HttpClient */ protected function getHttpClient() { if ($this->httpClient === null) { $config = [ 'base_uri' => ( ($this->getOption('mode', self::MODE_LIVE) == self::MODE_LIVE) ? self::DIRECTORY_LIVE : self::DIRECTORY_STAGING), ]; if ($this->getOption('source_ip', false) !== false) { $config['curl.options']['CURLOPT_INTERFACE'] = $this->getOption('source_ip'); } $this->httpClient = new HttpClient($config); } return $this->httpClient; } /** * Returns a Guzzle Client configured for self test * @return HttpClient */ protected function getSelfTestClient() { return new HttpClient([ 'verify' => false, 'timeout' => 10, 'connect_timeout' => 3, 'allow_redirects' => true, ]); } /** * Self HTTP test * @param Authorization $authorization * @param $maxAttempts * @return bool */ protected function selfHttpTest(Authorization $authorization, $maxAttempts) { do { $maxAttempts--; try { $response = $this->getSelfTestClient()->request( 'GET', 'http://' . $authorization->getDomain() . '/.well-known/acme-challenge/' . $authorization->getFile()->getFilename() ); $contents = (string) $response->getBody(); if ($contents == $authorization->getFile()->getContents()) { return true; } } catch (RequestException $e) { } } while ($maxAttempts > 0); 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 */ protected function init() { //Load the directories from the LE api $response = $this->getHttpClient()->get('/directory'); $result = \GuzzleHttp\json_decode((string) $response->getBody(), true); $this->directories = $result; //Prepare LE account $this->loadKeys(); $this->tosAgree(); $this->account = $this->getAccount(); } protected function loadKeys() { //Make sure a private key is in place if ($this->getFilesystem()->has($this->getPath('account.pem')) === false) { $this->getFilesystem()->write( $this->getPath('account.pem'), Helper::getNewKey($this->getOption('key_length', 4096)) ); } $privateKey = $this->getFilesystem()->read($this->getPath('account.pem')); $privateKey = openssl_pkey_get_private($privateKey); $this->privateKeyDetails = openssl_pkey_get_details($privateKey); } /** * Agree to the terms of service * * @throws \Exception */ protected function tosAgree() { $this->request( $this->getUrl(self::DIRECTORY_NEW_ACCOUNT), $this->signPayloadJWK( [ 'contact' => [ 'mailto:' . $this->getOption('username'), ], 'termsOfServiceAgreed' => true, ], $this->getUrl(self::DIRECTORY_NEW_ACCOUNT) ) ); } /** * Get a formatted path * * @param null $path * @return string */ protected function getPath($path = null): string { $userDirectory = preg_replace('/[^a-z0-9]+/', '-', strtolower($this->getOption('username'))); return $this->getOption( 'basePath', 'le' ) . DIRECTORY_SEPARATOR . $userDirectory . ($path === null ? '' : DIRECTORY_SEPARATOR . $path); } /** * Return the Flysystem filesystem * @return Filesystem */ protected function getFilesystem(): Filesystem { return $this->filesystem; } /** * Get a defined option * * @param $key * @param null $default * * @return mixed|null */ protected function getOption($key, $default = null) { if (isset($this->config[$key])) { return $this->config[$key]; } return $default; } /** * Get key fingerprint * * @return string * @throws \Exception */ protected function getDigest(): string { if ($this->digest === null) { $this->digest = Helper::toSafeString(hash('sha256', json_encode($this->getJWKHeader()), true)); } return $this->digest; } /** * Send a request to the LE API * * @param $url * @param array $payload * @param string $method * @return ResponseInterface */ protected function request($url, $payload = [], $method = 'POST'): ResponseInterface { try { $response = $this->getHttpClient()->request($method, $url, [ 'json' => $payload, 'headers' => [ 'Content-Type' => 'application/jose+json', ] ]); $this->nonce = $response->getHeaderLine('replay-nonce'); } catch (ClientException $e) { throw $e; } return $response; } /** * Get the LE directory path * * @param $directory * * @return mixed * @throws \Exception */ protected function getUrl($directory): string { if (isset($this->directories[$directory])) { return $this->directories[$directory]; } throw new \Exception('Invalid directory: ' . $directory . ' not listed'); } /** * Get the key * * @return bool|resource|string * @throws \Exception */ protected function getAccountKey() { if ($this->accountKey === null) { $this->accountKey = openssl_pkey_get_private($this->getFilesystem() ->read($this->getPath('account.pem'))); } if ($this->accountKey === false) { throw new \Exception('Invalid account key'); } return $this->accountKey; } /** * Get the header * * @return array * @throws \Exception */ protected function getJWKHeader(): array { return [ 'e' => Helper::toSafeString(Helper::getKeyDetails($this->getAccountKey())['rsa']['e']), 'kty' => 'RSA', 'n' => Helper::toSafeString(Helper::getKeyDetails($this->getAccountKey())['rsa']['n']), ]; } /** * Get JWK envelope * * @param $url * @return array * @throws \Exception */ protected function getJWK($url): array { //Require a nonce to be available if ($this->nonce === null) { $response = $this->getHttpClient()->head($this->directories[self::DIRECTORY_NEW_NONCE]); $this->nonce = $response->getHeaderLine('replay-nonce'); } return [ 'alg' => 'RS256', 'jwk' => $this->getJWKHeader(), 'nonce' => $this->nonce, 'url' => $url ]; } /** * Get KID envelope * * @param $url * @param $kid * @return array */ protected function getKID($url): array { $response = $this->getHttpClient()->head($this->directories[self::DIRECTORY_NEW_NONCE]); $nonce = $response->getHeaderLine('replay-nonce'); return [ "alg" => "RS256", "kid" => $this->account->getAccountURL(), "nonce" => $nonce, "url" => $url ]; } /** * Transform the payload to the JWS format * * @param $payload * @param $url * @return array * @throws \Exception */ protected function signPayloadJWK($payload, $url): array { $payload = is_array($payload) ? str_replace('\\/', '/', json_encode($payload)) : ''; $payload = Helper::toSafeString($payload); $protected = Helper::toSafeString(json_encode($this->getJWK($url))); $result = openssl_sign($protected . '.' . $payload, $signature, $this->getAccountKey(), "SHA256"); if ($result === false) { throw new \Exception('Could not sign'); } return [ 'protected' => $protected, 'payload' => $payload, 'signature' => Helper::toSafeString($signature), ]; } /** * Transform the payload to the KID format * * @param $payload * @param $url * @return array * @throws \Exception */ protected function signPayloadKid($payload, $url): array { $payload = is_array($payload) ? str_replace('\\/', '/', json_encode($payload)) : ''; $payload = Helper::toSafeString($payload); $protected = Helper::toSafeString(json_encode($this->getKID($url))); $result = openssl_sign($protected . '.' . $payload, $signature, $this->getAccountKey(), "SHA256"); if ($result === false) { throw new \Exception('Could not sign'); } return [ 'protected' => $protected, 'payload' => $payload, 'signature' => Helper::toSafeString($signature), ]; } } src/Helper.php000064400000010040152232653260007264 0ustar00setTimestamp($info['validTo_time_t']); return $dateTime; } /** * Get a new key * * @return string */ public static function getNewKey(int $keyLength): string { $key = openssl_pkey_new([ 'private_key_bits' => $keyLength, 'private_key_type' => OPENSSL_KEYTYPE_RSA, ]); openssl_pkey_export($key, $pem); return $pem; } /** * Get a new CSR * * @param array $domains * @param $key * * @return string * @throws \Exception */ public static function getCsr(array $domains, $key): string { $primaryDomain = current(($domains)); $config = [ '[req]', 'distinguished_name=req_distinguished_name', '[req_distinguished_name]', '[v3_req]', '[v3_ca]', '[SAN]', 'subjectAltName=' . implode(',', array_map(function ($domain) { return 'DNS:' . $domain; }, $domains)), ]; $fn = tempnam(sys_get_temp_dir(), md5(microtime(true))); file_put_contents($fn, implode("\n", $config)); $csr = openssl_csr_new([ 'countryName' => 'NL', 'commonName' => $primaryDomain, ], $key, [ 'config' => $fn, 'req_extensions' => 'SAN', 'digest_alg' => 'sha512', ]); unlink($fn); if ($csr === false) { throw new \Exception('Could not create a CSR'); } if (openssl_csr_export($csr, $result) == false) { throw new \Exception('CRS export failed'); } $result = trim($result); return $result; } /** * Make a safe base64 string * * @param $data * * @return string */ public static function toSafeString($data): string { return rtrim(strtr(base64_encode($data), '+/', '-_'), '='); } /** * Get the key information * * @return array * @throws \Exception */ public static function getKeyDetails($key): array { $accountDetails = openssl_pkey_get_details($key); if ($accountDetails === false) { throw new \Exception('Could not load account details'); } return $accountDetails; } /** * Split a two certificate bundle into separate multi line string certificates * @param string $chain * @return array * @throws \Exception */ public static function splitCertificate(string $chain): array { preg_match( '/^(?-----BEGIN CERTIFICATE-----.+?-----END CERTIFICATE-----)\n' . '(?-----BEGIN CERTIFICATE-----.+?-----END CERTIFICATE-----)$/s', $chain, $certificates ); $domain = $certificates['domain'] ?? null; $intermediate = $certificates['intermediate'] ?? null; if (!$domain || !$intermediate) { throw new \Exception('Could not parse certificate string'); } return [$domain, $intermediate]; } } LICENSE.md000064400000001047152232653260006160 0ustar00Copyright 2018 Afosto SaaS BV Licensed under the Apache License, Version 2.0 (the "License"); you may not use this file except in compliance with the License. You may obtain a copy of the License at http://www.apache.org/licenses/LICENSE-2.0 Unless required by applicable law or agreed to in writing, software distributed under the License is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the License for the specific language governing permissions and limitations under the License.README.md000064400000016114152232653260006034 0ustar00# yaac - Yet another ACME client Written in PHP, this client aims to be a simplified and decoupled Let’s Encrypt client, based on [ACME V2](https://tools.ietf.org/html/rfc8555). ## Decoupled from a filesystem or webserver Instead of, for example writing the certificate to the disk under an nginx configuration, this client just returns the data (the certificate and private key). ## Why Why would I need this package? At Afosto we run our software in a multi-tenant setup, as any other SaaS would do, and therefore we cannot make use of the many clients that are already out there. Almost all clients are coupled to a type of webserver or a fixed (set of) domain(s). This package can be extremely useful in case you need to dynamically fetch and install certificates. ## Requirements - PHP7+ - openssl - [Flysystem](http://flysystem.thephpleague.com/) (any adapter would do) - to store the Lets Encrypt account information ## Getting started Getting started is easy. First install the client, then you need to construct a flysystem filesystem, instantiate the client and you can start requesting certificates. ### Installation Installing this package is done easily with composer. ```bash composer require afosto/yaac ``` ### Instantiate the client To start the client you need 3 things; a username for your Let’s Encrypt account, a bootstrapped Flysystem and you need to decide whether you want to issue `Fake LE Intermediate X1` (staging: `MODE_STAGING`) or `Let's Encrypt Authority X3` (live: `MODE_LIVE`, use for production) certificates. ```php use League\Flysystem\Filesystem; use League\Flysystem\Adapter\Local; use Afosto\Acme\Client; //Prepare flysystem $adapter = new Local('data'); $filesystem = new Filesystem($adapter); //Construct the client $client = new Client([ 'username' => 'example@example.org', 'fs' => $filesystem, 'mode' => Client::MODE_STAGING, ]); ``` While you instantiate the client, when needed a new Let’s Encrypt account is created and then agrees to the TOS. ### Create an order To start retrieving certificates, we need to create an order first. This is done as follows: ```php $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). 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. #### 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 ```php foreach ($authorizations as $authorization) { $file = $authorization->getFile(); file_put_contents($file->getFilename(), $file->getContents()); } ``` > If you need a wildcard certificate, you will need to use DNS validation, see below #### 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 should perform a self test just to be sure it works before asking Let's Encrypt to validate ownership. For a HTTP challenge test call: ```php if (!$client->selfTest($authorization, Client::VALIDATION_HTTP)) { throw new \Exception('Could not verify ownership via HTTP'); } ``` For a DNS test call: ```php if (!$client->selfTest($authorization, Client::VALIDATION_DNS)) { throw new \Exception('Could not verify ownership via DNS'); } sleep(30); // this further sleep is recommended, depending on your DNS provider, see below ``` With DNS validation, after the `selfTest` has confirmed that DNS has been updated, it is recommended you wait some additional time before proceeding, e.g. `sleep(30);`. This is because Let’s Encrypt will perform [multiple viewpoint validation](https://community.letsencrypt.org/t/acme-v1-v2-validating-challenges-from-multiple-network-vantage-points/112253), and your DNS provider may not have completed propagating the changes across their network. If you proceed too soon, [Let's Encrypt will fail to validate](https://community.letsencrypt.org/t/during-secondary-validation-incorrect-txt-record/113643). ### Request validation Next step is to request validation of ownership. For each authorization (domain) we ask Let’s Encrypt to verify the challenge. For HTTP validation: ```php foreach ($authorizations as $authorization) { $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 Let’s Encrypt to validate the challenge (with 1 second intervals) and retrieve an updated status (it might take Let’s 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: ```php if ($client->isReady($order)) { //The validation was successful. } ``` We now know validation was completed and can obtain the certificate. This is done as follows: ```php $certificate = $client->getCertificate($order); ``` We now have the certificate, to store it on the filesystem: ```php //Store the certificate and private key where you need it file_put_contents('certificate.cert', $certificate->getCertificate()); file_put_contents('private.key', $certificate->getPrivateKey()); ``` >To get a seperate intermediate certificate and domain certificate: >```php >$domainCertificate = $certificate->getCertificate(false); >$intermediateCertificate = $certificate->getIntermediate(); >``` ### Who is using it? Are you using this package, would love to know. Please send a PR to enlist your project or company. - [Afosto SaaS BV](https://afosto.com) - [Aitrex - Free Let's Encrypt SSL Certificate Generator](https://aitrex.com/freessl.php) - [Web Whales](https://webwhales.nl) - [do.de](https://www.do.de) - [punchsalad.com](https://punchsalad.com/ssl-certificate-generator/) - [Spreadly](https://spreadly.app) - [SslForWeb](https://sslforweb.com) composer.json000064400000001152152232653260007273 0ustar00{ "name": "afosto/yaac", "description": "Yet Another ACME client: a decoupled LetsEncrypt client", "type": "package", "keywords": [ "afosto", "lets", "acme", "acmev2", "v2", "encrypt", "letsencrypt" ], "homepage": "https://afosto.com", "license": "Apache-2.0", "authors": [ { "name": "Afosto Team", "homepage": "https://afosto.com" } ], "autoload": { "psr-4": { "Afosto\\Acme\\": "./src" } }, "require": { "guzzlehttp/guzzle": "^6.3|^7.0", "league/flysystem": "^1.0|^3.0", "ext-openssl": "*", "ext-json": "*" } }