Initial commit

This commit is contained in:
peterbakker
2020-02-13 08:55:12 +01:00
commit 5a35b6af35
13 changed files with 1817 additions and 0 deletions

629
src/Client.php Normal file
View File

@@ -0,0 +1,629 @@
<?php
namespace Afosto\LetsEncrypt;
use Afosto\LetsEncrypt\Data\Account;
use Afosto\LetsEncrypt\Data\Authorization;
use Afosto\LetsEncrypt\Data\Certificate;
use Afosto\LetsEncrypt\Data\Challenge;
use Afosto\LetsEncrypt\Data\Order;
use GuzzleHttp\Client as HttpClient;
use GuzzleHttp\Exception\ClientException;
use League\Flysystem\Filesystem;
use LEClient\LEFunctions;
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';
/**
* @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
* }
*/
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 {
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,
$response->getHeaderLine('location'),
$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;
}
/**
* 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 {
$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');
return (isset($data['status']) && $data['status'] == 'ready');
}
/**
* 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'])
);
$certificate = $str = preg_replace('/^[ \t]*[\r\n]+/m', '', (string)$certificateResponse->getBody());
return new Certificate($privateKey, $csr, $certificate);
}
/**
* 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);
}
/**
* Initialize the client
*/
protected function init()
{
//Load the directories from the LE api
$response = $this->httpClient->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 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->httpClient->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->httpClient->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->httpClient->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),
];
}
}

87
src/Data/Account.php Normal file
View File

@@ -0,0 +1,87 @@
<?php
namespace Afosto\LetsEncrypt\Data;
class Account
{
/**
* @var array
*/
protected $contact;
/**
* @var string
*/
protected $createdAt;
/**
* @var bool
*/
protected $isValid;
/**
* @var
*/
protected $initialIp;
/**
* @var string
*/
protected $accountURL;
public function __construct(
array $contact,
\DateTime $createdAt,
bool $isValid,
string $initialIp,
string $accountURL
) {
$this->initialIp = $initialIp;
$this->contact = $contact;
$this->createdAt = $createdAt;
$this->isValid = $isValid;
$this->accountURL = $accountURL;
}
public function getId(): string
{
return substr($this->accountURL, strrpos($this->accountURL, '/') + 1);
}
public function getCreatedAt(): \DateTime
{
return $this->createdAt;
}
public function getAccountURL(): string
{
return $this->accountURL;
}
/**
* @return array
*/
public function getContact(): array
{
return $this->contact;
}
/**
* @return string
*/
public function getInitialIp(): string
{
return $this->initialIp;
}
/**
* @return bool
*/
public function isValid(): bool
{
return $this->isValid;
}
}

View File

@@ -0,0 +1,93 @@
<?php
namespace Afosto\LetsEncrypt\Data;
use Afosto\LetsEncrypt\Client;
class Authorization
{
/**
* @var string
*/
protected $domain;
/**
* @var \DateTime
*/
protected $expires;
/**
* @var Challenge[]
*/
protected $challenges = [];
/**
* @var string
*/
protected $digest;
public function __construct(string $domain, string $expires, string $digest)
{
$this->domain = $domain;
$this->expires = (new \DateTime())->setTimestamp(strtotime($expires));
$this->digest = $digest;
}
public function addChallenge(Challenge $challenge)
{
$this->challenges[] = $challenge;
}
/**
* @return array
*/
public function getDomain(): string
{
return $this->domain;
}
/**
* @return \DateTime
*/
public function getExpires(): \DateTime
{
return $this->expires;
}
/**
* @return Challenge[]
*/
public function getChallenges(): array
{
return $this->challenges;
}
/**
* @return Challenge|bool
*/
public function getHttpChallenge()
{
foreach ($this->getChallenges() as $challenge) {
if ($challenge->getType() == Client::VALIDATION_HTTP) {
return $challenge;
}
}
return false;
}
/**
* @param Challenge $challenge
* @return File|bool
*/
public function getFile(Challenge $challenge)
{
if ($challenge->getType() == Client::VALIDATION_HTTP) {
$file = new File($challenge->getToken(), $challenge->getToken() . '.' . $this->digest);
return $file;
}
return false;
}
}

76
src/Data/Certificate.php Normal file
View File

@@ -0,0 +1,76 @@
<?php
namespace Afosto\LetsEncrypt\Data;
use Afosto\LetsEncrypt\Helper;
class Certificate
{
/**
* @var string
*/
protected $privateKey;
/**
* @var string
*/
protected $certificate;
/**
* @var string
*/
protected $csr;
/**
* @var \DateTime
*/
protected $expiryDate;
/**
* Certificate constructor.
* @param $privateKey
* @param $csr
* @param $certificate
* @throws \Exception
*/
public function __construct($privateKey, $csr, $certificate)
{
$this->privateKey = $privateKey;
$this->csr = $csr;
$this->certificate = $certificate;
$this->expiryDate = Helper::getCertExpiryDate($certificate);
}
/**
* @return string
*/
public function getCsr(): string
{
return $this->csr;
}
/**
* @return \DateTime
*/
public function getExpiryDate(): \DateTime
{
return $this->expiryDate;
}
/**
* @return string
*/
public function getCertificate(): string
{
return $this->certificate;
}
/**
* @return string
*/
public function getPrivateKey(): string
{
return $this->privateKey;
}
}

83
src/Data/Challenge.php Normal file
View File

@@ -0,0 +1,83 @@
<?php
namespace Afosto\LetsEncrypt\Data;
class Challenge
{
/**
* @var string
*/
protected $authorizationURL;
/**
* @var string
*/
protected $type;
/**
* @var string
*/
protected $status;
/**
* @var string
*/
protected $url;
/**
* @var string
*/
protected $token;
/**
* Challenge constructor.
* @param string $authorizationURL
* @param string $type
* @param string $status
* @param string $url
* @param string $token
*/
public function __construct(string $authorizationURL, string $type, string $status, string $url, string $token)
{
$this->authorizationURL = $authorizationURL;
$this->type = $type;
$this->status = $status;
$this->url = $url;
$this->token = $token;
}
/**
* @return string
*/
public function getUrl(): string
{
return $this->url;
}
/**
* @return string
*/
public function getType(): string
{
return $this->type;
}
/**
* @return string
*/
public function getToken(): string
{
return $this->token;
}
public function getStatus(): string
{
return $this->status;
}
public function getAuthorizationURL(): string
{
return $this->authorizationURL;
}
}

40
src/Data/File.php Normal file
View File

@@ -0,0 +1,40 @@
<?php
namespace Afosto\LetsEncrypt\Data;
class File
{
/**
* @var string
*/
protected $filename;
/**
* @var string
*/
protected $contents;
public function __construct(string $filename, string $contents)
{
$this->contents = $contents;
$this->filename = $filename;
}
/**
* @return string
*/
public function getFilename(): string
{
return $this->filename;
}
/**
* @return string
*/
public function getContents(): string
{
return $this->contents;
}
}

102
src/Data/Order.php Normal file
View File

@@ -0,0 +1,102 @@
<?php
namespace Afosto\LetsEncrypt\Data;
class Order
{
/**
* @var string
*/
protected $url;
/**
* @var string
*/
protected $status;
/**
* @var \DateTime
*/
protected $expiresAt;
/**
* @var array
*/
protected $identifiers;
/**
* @var array
*/
protected $authorizations;
/**
* @var string
*/
protected $finalizeURL;
/**
* @var array
*/
protected $domains;
public function __construct(
array $domains,
string $url,
string $status,
string $expiresAt,
array $identifiers,
array $authorizations,
string $finalizeURL
) {
$this->domains = $domains;
$this->url = $url;
$this->status = $status;
$this->expiresAt = (new \DateTime())->setTimestamp(strtotime($expiresAt));
$this->identifiers = $identifiers;
$this->authorizations = $authorizations;
$this->finalizeURL = $finalizeURL;
}
public function getId(): string
{
return substr($this->url, strrpos($this->url, '/') + 1);
}
public function getURL(): string
{
return $this->url;
}
public function getAuthorizationURLs(): array
{
return $this->authorizations;
}
public function getStatus(): string
{
return $this->status;
}
public function getExpiresAt(): \DateTime
{
return $this->expiresAt;
}
public function getIdentifiers(): array
{
return $this->identifiers;
}
public function getFinalizeURL(): string
{
return $this->finalizeURL;
}
public function getDomains(): array
{
return $this->domains;
}
}

138
src/Helper.php Normal file
View File

@@ -0,0 +1,138 @@
<?php
namespace Afosto\LetsEncrypt;
use Afosto\LetsEncrypt\Data\Authorization;
use GuzzleHttp\Client as HttpClient;
use GuzzleHttp\Exception\ClientException;
class Helper
{
/**
* Formatter
* @param $pem
* @return false|string
*/
public static function toDer($pem)
{
$lines = explode(PHP_EOL, $pem);
$lines = array_slice($lines, 1, -1);
return base64_decode(implode('', $lines));
}
/**
* Return certificate expiry date
*
* @param $certificate
*
* @return \DateTime
* @throws \Exception
*/
public static function getCertExpiryDate($certificate): \DateTime
{
$info = openssl_x509_parse($certificate);
if ($info === false) {
throw new \Exception('Could not parse certificate');
}
$dateTime = new \DateTime();
$dateTime->setTimestamp($info['validTo_time_t']);
return $dateTime;
}
/**
* Get a new key
*
* @return string
*/
public static function getNewKey(): string
{
$key = openssl_pkey_new([
'private_key_bits' => 4096,
'private_key_type' => OPENSSL_KEYTYPE_RSA,
]);
openssl_pkey_export($key, $pem);
return $pem;
}
/**
* Get a new CSR
*
* @param array $domains
* @param $key
*
* @return string
* @throws \Exception
*/
public static function getCsr(array $domains, $key): string
{
$primaryDomain = current(($domains));
$config = [
'[req]',
'distinguished_name=req_distinguished_name',
'[req_distinguished_name]',
'[v3_req]',
'[v3_ca]',
'[SAN]',
'subjectAltName=' . implode(',', array_map(function ($domain) {
return 'DNS:' . $domain;
}, $domains)),
];
$fn = tempnam(sys_get_temp_dir(), md5(microtime(true)));
file_put_contents($fn, implode("\n", $config));
$csr = openssl_csr_new([
'countryName' => 'NL',
'commonName' => $primaryDomain,
], $key, [
'config' => $fn,
'req_extensions' => 'SAN',
'digest_alg' => 'sha512',
]);
unlink($fn);
if ($csr === false) {
throw new \Exception('Could not create a CSR');
}
if (openssl_csr_export($csr, $result) == false) {
throw new \Exception('CRS export failed');
}
$result = trim($result);
return $result;
}
/**
* Make a safe base64 string
*
* @param $data
*
* @return string
*/
public static function toSafeString($data): string
{
return rtrim(strtr(base64_encode($data), '+/', '-_'), '=');
}
/**
* Get the key information
*
* @return array
* @throws \Exception
*/
public static function getKeyDetails($key): array
{
$accountDetails = openssl_pkey_get_details($key);
if ($accountDetails === false) {
throw new \Exception('Could not load account details');
}
return $accountDetails;
}
}