Files
yaac/src/Client.php
Tom Maaswinkel 7284608c2f Don't sleep when validation succesfull
Currently the validate function will always sleep, even if the validation was succesfull. Sleeping is only needed when we need to retry.
2020-09-08 11:36:29 +02:00

759 lines
20 KiB
PHP

<?php
namespace Afosto\Acme;
use Afosto\Acme\Data\Account;
use Afosto\Acme\Data\Authorization;
use Afosto\Acme\Data\Certificate;
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 Psr\Http\Message\ResponseInterface;
class Client
{
/**
* Live url
*/
const DIRECTORY_LIVE = 'https://acme-v02.api.letsencrypt.org/directory';
/**
* Staging url
*/
const DIRECTORY_STAGING = 'https://acme-staging-v02.api.letsencrypt.org/directory';
/**
* Flag for production
*/
const MODE_LIVE = 'live';
/**
* Flag for staging
*/
const MODE_STAGING = 'staging';
/**
* New account directory
*/
const DIRECTORY_NEW_ACCOUNT = 'newAccount';
/**
* Nonce directory
*/
const DIRECTORY_NEW_NONCE = 'newNonce';
/**
* Order certificate directory
*/
const DIRECTORY_NEW_ORDER = 'newOrder';
/**
* Http validation
*/
const VALIDATION_HTTP = 'http-01';
/**
* DNS validation
*/
const VALIDATION_DNS = 'dns-01';
/**
* @var string
*/
protected $nonce;
/**
* @var Account
*/
protected $account;
/**
* @var array
*/
protected $privateKeyDetails;
/**
* @var string
*/
protected $accountKey;
/**
* @var Filesystem
*/
protected $filesystem;
/**
* @var array
*/
protected $directories = [];
/**
* @var array
*/
protected $header = [];
/**
* @var string
*/
protected $digest;
/**
* @var HttpClient
*/
protected $httpClient;
/**
* @var array
*/
protected $config;
/**
* Client constructor.
*
* @param array $config
*
* @type string $mode The mode for ACME (production / staging)
* @type Filesystem $fs Filesystem for storage of static data
* @type string $basePath The base path for the filesystem (used to store account information and csr / keys
* @type string $username The acme username
* @type string $source_ip The source IP for Guzzle (via curl.options) to bind to (defaults to 0.0.0.0 [OS default])
* }
*/
public function __construct($config = [])
{
$this->config = $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);
}
}
/**
* Validate a challenge
*
* @param Challenge $challenge
* @param int $maxAttempts
* @return bool
* @throws \Exception
*/
public function validate(Challenge $challenge, $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();
$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'), $data['initialIp'], $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();
}
/**
* Load the keys in memory
*
* @throws \League\Flysystem\FileExistsException
* @throws \League\Flysystem\FileNotFoundException
*/
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());
}
$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),
];
}
}