Merge pull request #4 from afosto/feature/improved-validation

Improved http validation with exponential backoff
This commit is contained in:
Peter Bakker
2020-03-18 19:41:32 +01:00
committed by GitHub
9 changed files with 232 additions and 47 deletions

View File

@@ -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());
```
```
### 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 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 @@ class Client
$this->init();
}
/**
* Get an existing order by ID
*
@@ -160,7 +153,7 @@ class Client
return new Order(
$domains,
$response->getHeaderLine('location'),
$url,
$data['status'],
$data['expires'],
$data['identifiers'],
@@ -196,7 +189,7 @@ class Client
foreach ($domains as $domain) {
$identifiers[] =
[
'type' => 'dns',
'type' => 'dns',
'value' => $domain,
];
}
@@ -258,6 +251,20 @@ class Client
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 @@ class Client
$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 @@ class Client
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 @@ class Client
$this->getUrl(self::DIRECTORY_NEW_ACCOUNT),
$this->signPayloadJWK(
[
'contact' => [
'contact' => [
'mailto:' . $this->getOption('username'),
],
'termsOfServiceAgreed' => true,
@@ -415,6 +483,7 @@ class Client
}
/**
* Return the Flysystem filesystem
* @return Filesystem
*/
protected function getFilesystem(): Filesystem
@@ -465,8 +534,8 @@ class Client
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 @@ class Client
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 @@ class Client
{
//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 @@ class Client
*/
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 @@ class Client
return [
'protected' => $protected,
'payload' => $payload,
'payload' => $payload,
'signature' => Helper::toSafeString($signature),
];
}
@@ -622,7 +691,7 @@ class Client
return [
'protected' => $protected,
'payload' => $payload,
'payload' => $payload,
'signature' => Helper::toSafeString($signature),
];
}

View File

@@ -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 @@ class Account
$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 @@ class Account
}
/**
* Return initial IP
* @return string
*/
public function getInitialIp(): string
@@ -78,6 +99,7 @@ class Account
}
/**
* Returns validation status
* @return bool
*/
public function isValid(): bool

View File

@@ -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 @@ class Authorization
$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 @@ class Authorization
/**
* Return the expiry of the authorization
* @return \DateTime
*/
public function getExpires(): \DateTime
@@ -57,6 +70,7 @@ class Authorization
}
/**
* Return array of challenges
* @return Challenge[]
*/
public function getChallenges(): array
@@ -65,6 +79,7 @@ class Authorization
}
/**
* Return the HTTP challenge
* @return Challenge|bool
*/
public function getHttpChallenge()
@@ -79,14 +94,14 @@ class Authorization
}
/**
* @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;
}

View File

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

View File

@@ -48,6 +48,7 @@ class Challenge
}
/**
* Get the URL for the challenge
* @return string
*/
public function getUrl(): string
@@ -56,6 +57,7 @@ class Challenge
}
/**
* Returns challenge type (DNS or HTTP)
* @return string
*/
public function getType(): string
@@ -64,6 +66,7 @@ class Challenge
}
/**
* Returns the token
* @return string
*/
public function getToken(): string
@@ -71,11 +74,19 @@ class Challenge
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;

View File

@@ -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 @@ class File
}
/**
* Return the filename for HTTP validation
* @return string
*/
public function getFilename(): string
@@ -31,6 +36,7 @@ class File
}
/**
* Return the file contents for HTTP validation
* @return string
*/
public function getContents(): string

View File

@@ -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 @@ class Order
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 @@ class Order
$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;

View File

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