diff --git a/README.md b/README.md index 75f9e4d..7e631d3 100644 --- a/README.md +++ b/README.md @@ -1,6 +1,6 @@ # yaac - Yet another ACME client -Written in PHP, this client aims to be a decoupled LetsEncrypt client, based on ACME V2. +Written in PHP, this client aims to be a simplified and decoupled LetsEncrypt client, based on ACME V2. ## Decoupled from a filesystem or webserver @@ -9,7 +9,7 @@ data (the certificate and private key). ## Why -Why whould I need this package? At Afosto we run our software in a multi tenant setup, as any other SaaS would do, and +Why whould 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 @@ -92,9 +92,7 @@ Use the following example to get the HTTP validation going. First obtain the cha challenges accessible from ```php foreach ($authorizations as $authorization) { - $challenge = $authorization->getHttpChallenge(); - - $file = $authorization->getFile($challenge); + $file = $authorization->getFile(); file_put_contents($file->getFilename(), $file->getContents()); } ``` @@ -109,16 +107,19 @@ challenge. ```php foreach ($authorizations as $authorization) { - $ok = $client->validate($authorization->getHttpChallenge(), 15); + if ($client->selfTest($authorization, Client::VALIDATION_HTTP)) { + $client->validate($authorization->getHttpChallenge(), 15); + } + } ``` -The method above will perform 15 attempts to ask LetsEncrypt to validate the challenge (with 1 second intervals) and +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 validation was successful, test if the order is ready as follows: +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)) { @@ -137,4 +138,9 @@ We now have the certificate, to store it on the filesystem: //Store the certificate and private key where you need it file_put_contents('certificate.cert', $certificate->getCertificate()); file_put_contents('private.key', $certificate->getPrivateKey()); -``` \ No newline at end of file +``` + +### 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) \ No newline at end of file diff --git a/src/Client.php b/src/Client.php index d3d67c0..27d1996 100644 --- a/src/Client.php +++ b/src/Client.php @@ -9,8 +9,8 @@ use Afosto\Acme\Data\Order; use GuzzleHttp\Client as HttpClient; use GuzzleHttp\Exception\ClientException; +use GuzzleHttp\Exception\RequestException; use League\Flysystem\Filesystem; -use LEClient\LEFunctions; use Psr\Http\Message\ResponseInterface; class Client @@ -119,12 +119,6 @@ class Client public function __construct($config = []) { $this->config = $config; - $this->httpClient = new HttpClient([ - 'base_uri' => ( - ($this->getOption('mode', self::MODE_LIVE) == self::MODE_LIVE) ? - self::DIRECTORY_LIVE : self::DIRECTORY_STAGING), - ]); - if ($this->getOption('fs', false)) { $this->filesystem = $this->getOption('fs'); } else { @@ -138,7 +132,6 @@ public function __construct($config = []) $this->init(); } - /** * Get an existing order by ID * @@ -160,7 +153,7 @@ public function getOrder($id): Order return new Order( $domains, - $response->getHeaderLine('location'), + $url, $data['status'], $data['expires'], $data['identifiers'], @@ -196,7 +189,7 @@ public function createOrder(array $domains): Order foreach ($domains as $domain) { $identifiers[] = [ - 'type' => 'dns', + 'type' => 'dns', 'value' => $domain, ]; } @@ -258,6 +251,20 @@ public function authorize(Order $order): array 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); + } + } + /** * Validate a challenge * @@ -277,14 +284,14 @@ public function validate(Challenge $challenge, $maxAttempts = 15): bool $data = []; do { - $maxAttempts--; $response = $this->request( $challenge->getAuthorizationURL(), $this->signPayloadKid(null, $challenge->getAuthorizationURL()) ); $data = json_decode((string)$response->getBody(), true); - sleep(1); - } while ($maxAttempts > 0 && $data['status'] == 'pending'); + sleep(ceil(15 / $maxAttempts)); + $maxAttempts--; + } while ($maxAttempts > 0 && $data['status'] != 'valid'); return (isset($data['status']) && $data['status'] == 'valid'); } @@ -344,13 +351,74 @@ public function getAccount(): Account return new Account($data['contact'], $date, ($data['status'] == 'valid'), $data['initialIp'], $accountURL); } + /** + * Returns the ACME api configured Guzzle Client + * @return HttpClient + */ + protected function getHttpClient() + { + if ($this->httpClient === null) { + $this->httpClient = new HttpClient([ + 'base_uri' => ( + ($this->getOption('mode', self::MODE_LIVE) == self::MODE_LIVE) ? + self::DIRECTORY_LIVE : self::DIRECTORY_STAGING), + ]); + } + 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) + { + $file = $authorization->getFile(); + $authorization->getDomain(); + do { + $maxAttempts--; + + try { + $response = $this->getSelfTestClient()->request( + 'GET', + 'http://' . $authorization->getDomain() . '/.well-known/acme-challenge/' . $file->getFilename() + ); + $contents = (string)$response->getBody(); + if ($contents == $file->getContents()) { + { + return true; + } + } + } catch (RequestException $e) { + } + } while ($maxAttempts > 0); + + return false; + } + /** * Initialize the client */ protected function init() { //Load the directories from the LE api - $response = $this->httpClient->get('/directory'); + $response = $this->getHttpClient()->get('/directory'); $result = \GuzzleHttp\json_decode((string)$response->getBody(), true); $this->directories = $result; @@ -388,7 +456,7 @@ protected function tosAgree() $this->getUrl(self::DIRECTORY_NEW_ACCOUNT), $this->signPayloadJWK( [ - 'contact' => [ + 'contact' => [ 'mailto:' . $this->getOption('username'), ], 'termsOfServiceAgreed' => true, @@ -415,6 +483,7 @@ protected function getPath($path = null): string } /** + * Return the Flysystem filesystem * @return Filesystem */ protected function getFilesystem(): Filesystem @@ -465,8 +534,8 @@ protected function getDigest(): string protected function request($url, $payload = [], $method = 'POST'): ResponseInterface { try { - $response = $this->httpClient->request($method, $url, [ - 'json' => $payload, + $response = $this->getHttpClient()->request($method, $url, [ + 'json' => $payload, 'headers' => [ 'Content-Type' => 'application/jose+json', ] @@ -526,9 +595,9 @@ protected function getAccountKey() protected function getJWKHeader(): array { return [ - 'e' => Helper::toSafeString(Helper::getKeyDetails($this->getAccountKey())['rsa']['e']), + 'e' => Helper::toSafeString(Helper::getKeyDetails($this->getAccountKey())['rsa']['e']), 'kty' => 'RSA', - 'n' => Helper::toSafeString(Helper::getKeyDetails($this->getAccountKey())['rsa']['n']), + 'n' => Helper::toSafeString(Helper::getKeyDetails($this->getAccountKey())['rsa']['n']), ]; } @@ -543,14 +612,14 @@ protected function getJWK($url): array { //Require a nonce to be available if ($this->nonce === null) { - $response = $this->httpClient->head($this->directories[self::DIRECTORY_NEW_NONCE]); + $response = $this->getHttpClient()->head($this->directories[self::DIRECTORY_NEW_NONCE]); $this->nonce = $response->getHeaderLine('replay-nonce'); } return [ - 'alg' => 'RS256', - 'jwk' => $this->getJWKHeader(), + 'alg' => 'RS256', + 'jwk' => $this->getJWKHeader(), 'nonce' => $this->nonce, - 'url' => $url + 'url' => $url ]; } @@ -563,14 +632,14 @@ protected function getJWK($url): array */ protected function getKID($url): array { - $response = $this->httpClient->head($this->directories[self::DIRECTORY_NEW_NONCE]); + $response = $this->getHttpClient()->head($this->directories[self::DIRECTORY_NEW_NONCE]); $nonce = $response->getHeaderLine('replay-nonce'); return [ - "alg" => "RS256", - "kid" => $this->account->getAccountURL(), + "alg" => "RS256", + "kid" => $this->account->getAccountURL(), "nonce" => $nonce, - "url" => $url + "url" => $url ]; } @@ -596,7 +665,7 @@ protected function signPayloadJWK($payload, $url): array return [ 'protected' => $protected, - 'payload' => $payload, + 'payload' => $payload, 'signature' => Helper::toSafeString($signature), ]; } @@ -622,7 +691,7 @@ protected function signPayloadKid($payload, $url): array return [ 'protected' => $protected, - 'payload' => $payload, + 'payload' => $payload, 'signature' => Helper::toSafeString($signature), ]; } diff --git a/src/Data/Account.php b/src/Data/Account.php index 4cbcf49..9a957c8 100644 --- a/src/Data/Account.php +++ b/src/Data/Account.php @@ -31,6 +31,14 @@ class Account protected $accountURL; + /** + * Account constructor. + * @param array $contact + * @param \DateTime $createdAt + * @param bool $isValid + * @param string $initialIp + * @param string $accountURL + */ public function __construct( array $contact, \DateTime $createdAt, @@ -45,23 +53,35 @@ public function __construct( $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 @@ -70,6 +90,7 @@ public function getContact(): array } /** + * Return initial IP * @return string */ public function getInitialIp(): string @@ -78,6 +99,7 @@ public function getInitialIp(): string } /** + * Returns validation status * @return bool */ public function isValid(): bool diff --git a/src/Data/Authorization.php b/src/Data/Authorization.php index 866d3cf..7760a1c 100644 --- a/src/Data/Authorization.php +++ b/src/Data/Authorization.php @@ -27,6 +27,13 @@ class Authorization */ protected $digest; + /** + * Authorization constructor. + * @param string $domain + * @param string $expires + * @param string $digest + * @throws \Exception + */ public function __construct(string $domain, string $expires, string $digest) { $this->domain = $domain; @@ -34,13 +41,18 @@ public function __construct(string $domain, string $expires, string $digest) $this->digest = $digest; } + /** + * Add a challenge to the authorization + * @param Challenge $challenge + */ public function addChallenge(Challenge $challenge) { $this->challenges[] = $challenge; } /** - * @return array + * Return the domain that is being authorized + * @return string */ public function getDomain(): string { @@ -49,6 +61,7 @@ public function getDomain(): string /** + * Return the expiry of the authorization * @return \DateTime */ public function getExpires(): \DateTime @@ -57,6 +70,7 @@ public function getExpires(): \DateTime } /** + * Return array of challenges * @return Challenge[] */ public function getChallenges(): array @@ -65,6 +79,7 @@ public function getChallenges(): array } /** + * Return the HTTP challenge * @return Challenge|bool */ public function getHttpChallenge() @@ -79,14 +94,14 @@ public function getHttpChallenge() } /** - * @param Challenge $challenge + * Return File object for the given challenge * @return File|bool */ - public function getFile(Challenge $challenge) + public function getFile() { - if ($challenge->getType() == Client::VALIDATION_HTTP) { - $file = new File($challenge->getToken(), $challenge->getToken() . '.' . $this->digest); - return $file; + $challenge = $this->getHttpChallenge(); + if ($challenge !== false) { + return new File($challenge->getToken(), $challenge->getToken() . '.' . $this->digest); } return false; } diff --git a/src/Data/Certificate.php b/src/Data/Certificate.php index 71464ee..9fda72e 100644 --- a/src/Data/Certificate.php +++ b/src/Data/Certificate.php @@ -43,6 +43,7 @@ public function __construct($privateKey, $csr, $certificate) } /** + * Get the certificate signing request * @return string */ public function getCsr(): string @@ -51,6 +52,7 @@ public function getCsr(): string } /** + * Get the expiry date of the current certificate * @return \DateTime */ public function getExpiryDate(): \DateTime @@ -59,6 +61,7 @@ public function getExpiryDate(): \DateTime } /** + * Return the certificate as a multi line string * @return string */ public function getCertificate(): string @@ -67,6 +70,7 @@ public function getCertificate(): string } /** + * Return the private key as a multi line string * @return string */ public function getPrivateKey(): string diff --git a/src/Data/Challenge.php b/src/Data/Challenge.php index a33c78e..95493b3 100644 --- a/src/Data/Challenge.php +++ b/src/Data/Challenge.php @@ -48,6 +48,7 @@ public function __construct(string $authorizationURL, string $type, string $stat } /** + * Get the URL for the challenge * @return string */ public function getUrl(): string @@ -56,6 +57,7 @@ public function getUrl(): string } /** + * Returns challenge type (DNS or HTTP) * @return string */ public function getType(): string @@ -64,6 +66,7 @@ public function getType(): string } /** + * Returns the token * @return string */ public function getToken(): string @@ -71,11 +74,19 @@ 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; diff --git a/src/Data/File.php b/src/Data/File.php index 2ef719b..8b8e39a 100644 --- a/src/Data/File.php +++ b/src/Data/File.php @@ -15,7 +15,11 @@ class File */ protected $contents; - + /** + * File constructor. + * @param string $filename + * @param string $contents + */ public function __construct(string $filename, string $contents) { $this->contents = $contents; @@ -23,6 +27,7 @@ public function __construct(string $filename, string $contents) } /** + * Return the filename for HTTP validation * @return string */ public function getFilename(): string @@ -31,6 +36,7 @@ public function getFilename(): string } /** + * Return the file contents for HTTP validation * @return string */ public function getContents(): string diff --git a/src/Data/Order.php b/src/Data/Order.php index 8b2ad83..dfa4f7e 100644 --- a/src/Data/Order.php +++ b/src/Data/Order.php @@ -41,7 +41,17 @@ class Order */ protected $domains; - + /** + * Order constructor. + * @param array $domains + * @param string $url + * @param string $status + * @param string $expiresAt + * @param array $identifiers + * @param array $authorizations + * @param string $finalizeURL + * @throws \Exception + */ public function __construct( array $domains, string $url, @@ -51,6 +61,10 @@ public function __construct( array $authorizations, string $finalizeURL ) { + //Handle the microtime date format + if (strpos($expiresAt, '.') !== false) { + $expiresAt = substr($expiresAt, 0, strpos($expiresAt, '.')) . 'Z'; + } $this->domains = $domains; $this->url = $url; $this->status = $status; @@ -60,41 +74,74 @@ public function __construct( $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 Authorization[] + */ 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; diff --git a/src/Helper.php b/src/Helper.php index b29534f..70b5ed8 100644 --- a/src/Helper.php +++ b/src/Helper.php @@ -6,6 +6,11 @@ use GuzzleHttp\Client as HttpClient; use GuzzleHttp\Exception\ClientException; +/** + * Class Helper + * This class contains helper methods for certificate handling + * @package Afosto\Acme + */ class Helper {