Skip to content

Commit

Permalink
Merge pull request #4 from afosto/feature/improved-validation
Browse files Browse the repository at this point in the history
Improved http validation with exponential backoff
  • Loading branch information
bakkerpeter authored Mar 18, 2020
2 parents 03914ce + 451a21a commit ccaec49
Show file tree
Hide file tree
Showing 9 changed files with 232 additions and 47 deletions.
24 changes: 15 additions & 9 deletions README.md
Original file line number Diff line number Diff line change
@@ -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

Expand All @@ -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
Expand Down Expand Up @@ -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());
}
```
Expand All @@ -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)) {
Expand All @@ -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)
127 changes: 98 additions & 29 deletions src/Client.php
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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 {
Expand All @@ -138,7 +132,6 @@ public function __construct($config = [])
$this->init();
}


/**
* Get an existing order by ID
*
Expand All @@ -160,7 +153,7 @@ public function getOrder($id): Order

return new Order(
$domains,
$response->getHeaderLine('location'),
$url,
$data['status'],
$data['expires'],
$data['identifiers'],
Expand Down Expand Up @@ -196,7 +189,7 @@ public function createOrder(array $domains): Order
foreach ($domains as $domain) {
$identifiers[] =
[
'type' => 'dns',
'type' => 'dns',
'value' => $domain,
];
}
Expand Down Expand Up @@ -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
*
Expand All @@ -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');
}
Expand Down Expand Up @@ -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;

Expand Down Expand Up @@ -388,7 +456,7 @@ protected function tosAgree()
$this->getUrl(self::DIRECTORY_NEW_ACCOUNT),
$this->signPayloadJWK(
[
'contact' => [
'contact' => [
'mailto:' . $this->getOption('username'),
],
'termsOfServiceAgreed' => true,
Expand All @@ -415,6 +483,7 @@ protected function getPath($path = null): string
}

/**
* Return the Flysystem filesystem
* @return Filesystem
*/
protected function getFilesystem(): Filesystem
Expand Down Expand Up @@ -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',
]
Expand Down Expand Up @@ -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']),
];
}

Expand All @@ -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
];
}

Expand All @@ -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
];
}

Expand All @@ -596,7 +665,7 @@ protected function signPayloadJWK($payload, $url): array

return [
'protected' => $protected,
'payload' => $payload,
'payload' => $payload,
'signature' => Helper::toSafeString($signature),
];
}
Expand All @@ -622,7 +691,7 @@ protected function signPayloadKid($payload, $url): array

return [
'protected' => $protected,
'payload' => $payload,
'payload' => $payload,
'signature' => Helper::toSafeString($signature),
];
}
Expand Down
24 changes: 23 additions & 1 deletion src/Data/Account.php
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand All @@ -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
Expand All @@ -70,6 +90,7 @@ public function getContact(): array
}

/**
* Return initial IP
* @return string
*/
public function getInitialIp(): string
Expand All @@ -78,6 +99,7 @@ public function getInitialIp(): string
}

/**
* Returns validation status
* @return bool
*/
public function isValid(): bool
Expand Down
Loading

0 comments on commit ccaec49

Please sign in to comment.