Improved http validation with exponential backoff

Added documentation
Simplified HTTP validation flow (no longer need challenge to get file contents)
Updated README.md
This commit is contained in:
peterbakker
2020-03-18 19:31:57 +01:00
parent 03914ce189
commit b7ff268e4e
9 changed files with 228 additions and 46 deletions

View File

@@ -1,6 +1,6 @@
# yaac - Yet another ACME client # 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 ## Decoupled from a filesystem or webserver
@@ -9,7 +9,7 @@ data (the certificate and private key).
## Why ## 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. 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 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 challenges accessible from
```php ```php
foreach ($authorizations as $authorization) { foreach ($authorizations as $authorization) {
$challenge = $authorization->getHttpChallenge(); $file = $authorization->getFile();
$file = $authorization->getFile($challenge);
file_put_contents($file->getFilename(), $file->getContents()); file_put_contents($file->getFilename(), $file->getContents());
} }
``` ```
@@ -109,7 +107,7 @@ challenge.
```php ```php
foreach ($authorizations as $authorization) { foreach ($authorizations as $authorization) {
$ok = $client->validate($authorization->getHttpChallenge(), 15); $client->validate($authorization->getHttpChallenge(), 15);
} }
``` ```
@@ -118,7 +116,7 @@ retrieve an updated status (it might take Lets Encrypt a few seconds to validate
### Get the certificate ### 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 ```php
if ($client->isReady($order)) { if ($client->isReady($order)) {
@@ -137,4 +135,9 @@ We now have the certificate, to store it on the filesystem:
//Store the certificate and private key where you need it //Store the certificate and private key where you need it
file_put_contents('certificate.cert', $certificate->getCertificate()); file_put_contents('certificate.cert', $certificate->getCertificate());
file_put_contents('private.key', $certificate->getPrivateKey()); file_put_contents('private.key', $certificate->getPrivateKey());
``` ```
### 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)

View File

@@ -9,8 +9,8 @@ use Afosto\Acme\Data\Challenge;
use Afosto\Acme\Data\Order; use Afosto\Acme\Data\Order;
use GuzzleHttp\Client as HttpClient; use GuzzleHttp\Client as HttpClient;
use GuzzleHttp\Exception\ClientException; use GuzzleHttp\Exception\ClientException;
use GuzzleHttp\Exception\RequestException;
use League\Flysystem\Filesystem; use League\Flysystem\Filesystem;
use LEClient\LEFunctions;
use Psr\Http\Message\ResponseInterface; use Psr\Http\Message\ResponseInterface;
class Client class Client
@@ -119,12 +119,6 @@ class Client
public function __construct($config = []) public function __construct($config = [])
{ {
$this->config = $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)) { if ($this->getOption('fs', false)) {
$this->filesystem = $this->getOption('fs'); $this->filesystem = $this->getOption('fs');
} else { } else {
@@ -138,7 +132,6 @@ class Client
$this->init(); $this->init();
} }
/** /**
* Get an existing order by ID * Get an existing order by ID
* *
@@ -160,7 +153,7 @@ class Client
return new Order( return new Order(
$domains, $domains,
$response->getHeaderLine('location'), $url,
$data['status'], $data['status'],
$data['expires'], $data['expires'],
$data['identifiers'], $data['identifiers'],
@@ -196,7 +189,7 @@ class Client
foreach ($domains as $domain) { foreach ($domains as $domain) {
$identifiers[] = $identifiers[] =
[ [
'type' => 'dns', 'type' => 'dns',
'value' => $domain, 'value' => $domain,
]; ];
} }
@@ -258,6 +251,20 @@ class Client
return $authorizations; 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 * Validate a challenge
* *
@@ -277,14 +284,14 @@ class Client
$data = []; $data = [];
do { do {
$maxAttempts--;
$response = $this->request( $response = $this->request(
$challenge->getAuthorizationURL(), $challenge->getAuthorizationURL(),
$this->signPayloadKid(null, $challenge->getAuthorizationURL()) $this->signPayloadKid(null, $challenge->getAuthorizationURL())
); );
$data = json_decode((string)$response->getBody(), true); $data = json_decode((string)$response->getBody(), true);
sleep(1); sleep(ceil(15 / $maxAttempts));
} while ($maxAttempts > 0 && $data['status'] == 'pending'); $maxAttempts--;
} while ($maxAttempts > 0 && $data['status'] != 'valid');
return (isset($data['status']) && $data['status'] == 'valid'); return (isset($data['status']) && $data['status'] == 'valid');
} }
@@ -344,13 +351,74 @@ class Client
return new Account($data['contact'], $date, ($data['status'] == 'valid'), $data['initialIp'], $accountURL); 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 * Initialize the client
*/ */
protected function init() protected function init()
{ {
//Load the directories from the LE api //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); $result = \GuzzleHttp\json_decode((string)$response->getBody(), true);
$this->directories = $result; $this->directories = $result;
@@ -388,7 +456,7 @@ class Client
$this->getUrl(self::DIRECTORY_NEW_ACCOUNT), $this->getUrl(self::DIRECTORY_NEW_ACCOUNT),
$this->signPayloadJWK( $this->signPayloadJWK(
[ [
'contact' => [ 'contact' => [
'mailto:' . $this->getOption('username'), 'mailto:' . $this->getOption('username'),
], ],
'termsOfServiceAgreed' => true, 'termsOfServiceAgreed' => true,
@@ -415,6 +483,7 @@ class Client
} }
/** /**
* Return the Flysystem filesystem
* @return Filesystem * @return Filesystem
*/ */
protected function getFilesystem(): Filesystem protected function getFilesystem(): Filesystem
@@ -465,8 +534,8 @@ class Client
protected function request($url, $payload = [], $method = 'POST'): ResponseInterface protected function request($url, $payload = [], $method = 'POST'): ResponseInterface
{ {
try { try {
$response = $this->httpClient->request($method, $url, [ $response = $this->getHttpClient()->request($method, $url, [
'json' => $payload, 'json' => $payload,
'headers' => [ 'headers' => [
'Content-Type' => 'application/jose+json', 'Content-Type' => 'application/jose+json',
] ]
@@ -526,9 +595,9 @@ class Client
protected function getJWKHeader(): array protected function getJWKHeader(): array
{ {
return [ return [
'e' => Helper::toSafeString(Helper::getKeyDetails($this->getAccountKey())['rsa']['e']), 'e' => Helper::toSafeString(Helper::getKeyDetails($this->getAccountKey())['rsa']['e']),
'kty' => 'RSA', 'kty' => 'RSA',
'n' => Helper::toSafeString(Helper::getKeyDetails($this->getAccountKey())['rsa']['n']), 'n' => Helper::toSafeString(Helper::getKeyDetails($this->getAccountKey())['rsa']['n']),
]; ];
} }
@@ -543,14 +612,14 @@ class Client
{ {
//Require a nonce to be available //Require a nonce to be available
if ($this->nonce === null) { 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'); $this->nonce = $response->getHeaderLine('replay-nonce');
} }
return [ return [
'alg' => 'RS256', 'alg' => 'RS256',
'jwk' => $this->getJWKHeader(), 'jwk' => $this->getJWKHeader(),
'nonce' => $this->nonce, 'nonce' => $this->nonce,
'url' => $url 'url' => $url
]; ];
} }
@@ -563,14 +632,14 @@ class Client
*/ */
protected function getKID($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'); $nonce = $response->getHeaderLine('replay-nonce');
return [ return [
"alg" => "RS256", "alg" => "RS256",
"kid" => $this->account->getAccountURL(), "kid" => $this->account->getAccountURL(),
"nonce" => $nonce, "nonce" => $nonce,
"url" => $url "url" => $url
]; ];
} }
@@ -596,7 +665,7 @@ class Client
return [ return [
'protected' => $protected, 'protected' => $protected,
'payload' => $payload, 'payload' => $payload,
'signature' => Helper::toSafeString($signature), 'signature' => Helper::toSafeString($signature),
]; ];
} }
@@ -622,7 +691,7 @@ class Client
return [ return [
'protected' => $protected, 'protected' => $protected,
'payload' => $payload, 'payload' => $payload,
'signature' => Helper::toSafeString($signature), 'signature' => Helper::toSafeString($signature),
]; ];
} }

View File

@@ -31,6 +31,14 @@ class Account
protected $accountURL; protected $accountURL;
/**
* Account constructor.
* @param array $contact
* @param \DateTime $createdAt
* @param bool $isValid
* @param string $initialIp
* @param string $accountURL
*/
public function __construct( public function __construct(
array $contact, array $contact,
\DateTime $createdAt, \DateTime $createdAt,
@@ -45,23 +53,35 @@ class Account
$this->accountURL = $accountURL; $this->accountURL = $accountURL;
} }
/**
* Return the account ID
* @return string
*/
public function getId(): string public function getId(): string
{ {
return substr($this->accountURL, strrpos($this->accountURL, '/') + 1); return substr($this->accountURL, strrpos($this->accountURL, '/') + 1);
} }
/**
* Return create date for the account
* @return \DateTime
*/
public function getCreatedAt(): \DateTime public function getCreatedAt(): \DateTime
{ {
return $this->createdAt; return $this->createdAt;
} }
/**
* Return the URL for the account
* @return string
*/
public function getAccountURL(): string public function getAccountURL(): string
{ {
return $this->accountURL; return $this->accountURL;
} }
/** /**
* Return contact data
* @return array * @return array
*/ */
public function getContact(): array public function getContact(): array
@@ -70,6 +90,7 @@ class Account
} }
/** /**
* Return initial IP
* @return string * @return string
*/ */
public function getInitialIp(): string public function getInitialIp(): string
@@ -78,6 +99,7 @@ class Account
} }
/** /**
* Returns validation status
* @return bool * @return bool
*/ */
public function isValid(): bool public function isValid(): bool

View File

@@ -27,6 +27,13 @@ class Authorization
*/ */
protected $digest; protected $digest;
/**
* Authorization constructor.
* @param string $domain
* @param string $expires
* @param string $digest
* @throws \Exception
*/
public function __construct(string $domain, string $expires, string $digest) public function __construct(string $domain, string $expires, string $digest)
{ {
$this->domain = $domain; $this->domain = $domain;
@@ -34,13 +41,18 @@ class Authorization
$this->digest = $digest; $this->digest = $digest;
} }
/**
* Add a challenge to the authorization
* @param Challenge $challenge
*/
public function addChallenge(Challenge $challenge) public function addChallenge(Challenge $challenge)
{ {
$this->challenges[] = $challenge; $this->challenges[] = $challenge;
} }
/** /**
* @return array * Return the domain that is being authorized
* @return string
*/ */
public function getDomain(): string public function getDomain(): string
{ {
@@ -49,6 +61,7 @@ class Authorization
/** /**
* Return the expiry of the authorization
* @return \DateTime * @return \DateTime
*/ */
public function getExpires(): \DateTime public function getExpires(): \DateTime
@@ -57,6 +70,7 @@ class Authorization
} }
/** /**
* Return array of challenges
* @return Challenge[] * @return Challenge[]
*/ */
public function getChallenges(): array public function getChallenges(): array
@@ -65,6 +79,7 @@ class Authorization
} }
/** /**
* Return the HTTP challenge
* @return Challenge|bool * @return Challenge|bool
*/ */
public function getHttpChallenge() public function getHttpChallenge()
@@ -79,14 +94,14 @@ class Authorization
} }
/** /**
* @param Challenge $challenge * Return File object for the given challenge
* @return File|bool * @return File|bool
*/ */
public function getFile(Challenge $challenge) public function getFile()
{ {
if ($challenge->getType() == Client::VALIDATION_HTTP) { $challenge = $this->getHttpChallenge();
$file = new File($challenge->getToken(), $challenge->getToken() . '.' . $this->digest); if ($challenge !== false) {
return $file; return new File($challenge->getToken(), $challenge->getToken() . '.' . $this->digest);
} }
return false; return false;
} }

View File

@@ -43,6 +43,7 @@ class Certificate
} }
/** /**
* Get the certificate signing request
* @return string * @return string
*/ */
public function getCsr(): string public function getCsr(): string
@@ -51,6 +52,7 @@ class Certificate
} }
/** /**
* Get the expiry date of the current certificate
* @return \DateTime * @return \DateTime
*/ */
public function getExpiryDate(): \DateTime public function getExpiryDate(): \DateTime
@@ -59,6 +61,7 @@ class Certificate
} }
/** /**
* Return the certificate as a multi line string
* @return string * @return string
*/ */
public function getCertificate(): string public function getCertificate(): string
@@ -67,6 +70,7 @@ class Certificate
} }
/** /**
* Return the private key as a multi line string
* @return string * @return string
*/ */
public function getPrivateKey(): string public function getPrivateKey(): string

View File

@@ -48,6 +48,7 @@ class Challenge
} }
/** /**
* Get the URL for the challenge
* @return string * @return string
*/ */
public function getUrl(): string public function getUrl(): string
@@ -56,6 +57,7 @@ class Challenge
} }
/** /**
* Returns challenge type (DNS or HTTP)
* @return string * @return string
*/ */
public function getType(): string public function getType(): string
@@ -64,6 +66,7 @@ class Challenge
} }
/** /**
* Returns the token
* @return string * @return string
*/ */
public function getToken(): string public function getToken(): string
@@ -71,11 +74,19 @@ class Challenge
return $this->token; return $this->token;
} }
/**
* Returns the status
* @return string
*/
public function getStatus(): string public function getStatus(): string
{ {
return $this->status; return $this->status;
} }
/**
* Returns authorization URL
* @return string
*/
public function getAuthorizationURL(): string public function getAuthorizationURL(): string
{ {
return $this->authorizationURL; return $this->authorizationURL;

View File

@@ -15,7 +15,11 @@ class File
*/ */
protected $contents; protected $contents;
/**
* File constructor.
* @param string $filename
* @param string $contents
*/
public function __construct(string $filename, string $contents) public function __construct(string $filename, string $contents)
{ {
$this->contents = $contents; $this->contents = $contents;
@@ -23,6 +27,7 @@ class File
} }
/** /**
* Return the filename for HTTP validation
* @return string * @return string
*/ */
public function getFilename(): string public function getFilename(): string
@@ -31,6 +36,7 @@ class File
} }
/** /**
* Return the file contents for HTTP validation
* @return string * @return string
*/ */
public function getContents(): string public function getContents(): string

View File

@@ -41,7 +41,17 @@ class Order
*/ */
protected $domains; 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( public function __construct(
array $domains, array $domains,
string $url, string $url,
@@ -51,6 +61,10 @@ class Order
array $authorizations, array $authorizations,
string $finalizeURL string $finalizeURL
) { ) {
//Handle the microtime date format
if (strpos($expiresAt, '.') !== false) {
$expiresAt = substr($expiresAt, 0, strpos($expiresAt, '.')) . 'Z';
}
$this->domains = $domains; $this->domains = $domains;
$this->url = $url; $this->url = $url;
$this->status = $status; $this->status = $status;
@@ -60,41 +74,74 @@ class Order
$this->finalizeURL = $finalizeURL; $this->finalizeURL = $finalizeURL;
} }
/**
* Returns the order number
* @return string
*/
public function getId(): string public function getId(): string
{ {
return substr($this->url, strrpos($this->url, '/') + 1); return substr($this->url, strrpos($this->url, '/') + 1);
} }
/**
* Returns the order URL
* @return string
*/
public function getURL(): string public function getURL(): string
{ {
return $this->url; return $this->url;
} }
/**
* Return set of authorizations for the order
* @return Authorization[]
*/
public function getAuthorizationURLs(): array public function getAuthorizationURLs(): array
{ {
return $this->authorizations; return $this->authorizations;
} }
/**
* Returns order status
* @return string
*/
public function getStatus(): string public function getStatus(): string
{ {
return $this->status; return $this->status;
} }
/**
* Returns expires at
* @return \DateTime
*/
public function getExpiresAt(): \DateTime public function getExpiresAt(): \DateTime
{ {
return $this->expiresAt; return $this->expiresAt;
} }
/**
* Returs domains as identifiers
* @return array
*/
public function getIdentifiers(): array public function getIdentifiers(): array
{ {
return $this->identifiers; return $this->identifiers;
} }
/**
* Returns url
* @return string
*/
public function getFinalizeURL(): string public function getFinalizeURL(): string
{ {
return $this->finalizeURL; return $this->finalizeURL;
} }
/**
* Returns domains for the order
* @return array
*/
public function getDomains(): array public function getDomains(): array
{ {
return $this->domains; return $this->domains;

View File

@@ -6,6 +6,11 @@ use Afosto\Acme\Data\Authorization;
use GuzzleHttp\Client as HttpClient; use GuzzleHttp\Client as HttpClient;
use GuzzleHttp\Exception\ClientException; use GuzzleHttp\Exception\ClientException;
/**
* Class Helper
* This class contains helper methods for certificate handling
* @package Afosto\Acme
*/
class Helper class Helper
{ {