This commit is contained in:
2024-08-05 22:57:28 +08:00
commit 0efda6c02a
1779 changed files with 171774 additions and 0 deletions
+2
View File
@@ -0,0 +1,2 @@
composer.lock
vendor/
+404
View File
@@ -0,0 +1,404 @@
<?php
/*
* This file is part of the Acme PHP project.
*
* (c) Titouan Galopin <galopintitouan@gmail.com>
*
* For the full copyright and license information, please view the LICENSE
* file that was distributed with this source code.
*/
namespace AcmePhp\Core;
use AcmePhp\Core\Exception\AcmeCoreClientException;
use AcmePhp\Core\Exception\AcmeCoreServerException;
use AcmePhp\Core\Exception\Protocol\CertificateRequestFailedException;
use AcmePhp\Core\Exception\Protocol\CertificateRevocationException;
use AcmePhp\Core\Exception\Protocol\ChallengeFailedException;
use AcmePhp\Core\Exception\Protocol\ChallengeNotSupportedException;
use AcmePhp\Core\Exception\Protocol\ChallengeTimedOutException;
use AcmePhp\Core\Http\SecureHttpClient;
use AcmePhp\Core\Protocol\AuthorizationChallenge;
use AcmePhp\Core\Protocol\CertificateOrder;
use AcmePhp\Core\Protocol\ExternalAccount;
use AcmePhp\Core\Protocol\ResourcesDirectory;
use AcmePhp\Core\Protocol\RevocationReason;
use AcmePhp\Ssl\Certificate;
use AcmePhp\Ssl\CertificateRequest;
use AcmePhp\Ssl\CertificateResponse;
use AcmePhp\Ssl\Signer\CertificateRequestSigner;
use GuzzleHttp\Psr7\Utils;
use Webmozart\Assert\Assert;
/**
* ACME protocol client implementation.
*
* @author Titouan Galopin <galopintitouan@gmail.com>
*/
class AcmeClient implements AcmeClientInterface
{
/**
* @var SecureHttpClient
*/
private $uninitializedHttpClient;
/**
* @var SecureHttpClient
*/
private $initializedHttpClient;
/**
* @var CertificateRequestSigner
*/
private $csrSigner;
/**
* @var string
*/
private $directoryUrl;
/**
* @var ResourcesDirectory
*/
private $directory;
/**
* @var string
*/
private $account;
public function __construct(SecureHttpClient $httpClient, string $directoryUrl, CertificateRequestSigner $csrSigner = null)
{
$this->uninitializedHttpClient = $httpClient;
$this->directoryUrl = $directoryUrl;
$this->csrSigner = $csrSigner ?: new CertificateRequestSigner();
}
/**
* {@inheritdoc}
*/
public function registerAccount(string $email = null, ExternalAccount $externalAccount = null): array
{
$client = $this->getHttpClient();
$payload = [
'termsOfServiceAgreed' => true,
'contact' => [],
];
if ($email) {
$payload['contact'][] = 'mailto:'.$email;
}
if ($externalAccount) {
$payload['externalAccountBinding'] = $client->createExternalAccountPayload(
$externalAccount,
$this->getResourceUrl(ResourcesDirectory::NEW_ACCOUNT)
);
}
$this->requestResource('POST', ResourcesDirectory::NEW_ACCOUNT, $payload);
$account = $this->getResourceAccount();
return $client->request('POST', $account, $client->signKidPayload($account, $account, null));
}
/**
* {@inheritdoc}
*/
public function requestOrder(array $domains): CertificateOrder
{
Assert::allStringNotEmpty($domains, 'requestOrder::$domains expected a list of strings. Got: %s');
$payload = [
'identifiers' => array_map(
static function ($domain) {
return [
'type' => 'dns',
'value' => $domain,
];
},
array_values($domains)
),
];
$client = $this->getHttpClient();
$resourceUrl = $this->getResourceUrl(ResourcesDirectory::NEW_ORDER);
$response = $client->request('POST', $resourceUrl, $client->signKidPayload($resourceUrl, $this->getResourceAccount(), $payload));
if (!isset($response['authorizations']) || !$response['authorizations']) {
throw new ChallengeNotSupportedException();
}
$authorizationsChallenges = [];
$orderEndpoint = $client->getLastLocation();
foreach ($response['authorizations'] as $authorizationEndpoint) {
$authorizationsResponse = $client->request('POST', $authorizationEndpoint, $client->signKidPayload($authorizationEndpoint, $this->getResourceAccount(), null));
$domain = (empty($authorizationsResponse['wildcard']) ? '' : '*.').$authorizationsResponse['identifier']['value'];
foreach ($authorizationsResponse['challenges'] as $challenge) {
$authorizationsChallenges[$domain][] = $this->createAuthorizationChallenge($authorizationsResponse['identifier']['value'], $challenge);
}
}
return new CertificateOrder($authorizationsChallenges, $orderEndpoint, $response['status']);
}
/**
* {@inheritdoc}
*/
public function reloadOrder(CertificateOrder $order): CertificateOrder
{
$client = $this->getHttpClient();
$orderEndpoint = $order->getOrderEndpoint();
$response = $client->request('POST', $orderEndpoint, $client->signKidPayload($orderEndpoint, $this->getResourceAccount(), null));
if (!isset($response['authorizations']) || !$response['authorizations']) {
throw new ChallengeNotSupportedException();
}
$authorizationsChallenges = [];
foreach ($response['authorizations'] as $authorizationEndpoint) {
$authorizationsResponse = $client->request('POST', $authorizationEndpoint, $client->signKidPayload($authorizationEndpoint, $this->getResourceAccount(), null));
$domain = (empty($authorizationsResponse['wildcard']) ? '' : '*.').$authorizationsResponse['identifier']['value'];
foreach ($authorizationsResponse['challenges'] as $challenge) {
$authorizationsChallenges[$domain][] = $this->createAuthorizationChallenge($authorizationsResponse['identifier']['value'], $challenge);
}
}
return new CertificateOrder($authorizationsChallenges, $orderEndpoint, $response['status']);
}
/**
* {@inheritdoc}
*/
public function finalizeOrder(CertificateOrder $order, CertificateRequest $csr, int $timeout = 180, bool $returnAlternateCertificateIfAvailable = false): CertificateResponse
{
$endTime = time() + $timeout;
$client = $this->getHttpClient();
$orderEndpoint = $order->getOrderEndpoint();
$response = $client->request('POST', $orderEndpoint, $client->signKidPayload($orderEndpoint, $this->getResourceAccount(), null));
if (\in_array($response['status'], ['pending', 'processing', 'ready'])) {
$humanText = ['-----BEGIN CERTIFICATE REQUEST-----', '-----END CERTIFICATE REQUEST-----'];
$csrContent = $this->csrSigner->signCertificateRequest($csr);
$csrContent = trim(str_replace($humanText, '', $csrContent));
$csrContent = trim($client->getBase64Encoder()->encode(base64_decode($csrContent)));
$response = $client->request('POST', $response['finalize'], $client->signKidPayload($response['finalize'], $this->getResourceAccount(), ['csr' => $csrContent]));
}
// Waiting loop
while (time() <= $endTime && (!isset($response['status']) || \in_array($response['status'], ['pending', 'processing', 'ready']))) {
sleep(1);
$response = $client->request('POST', $orderEndpoint, $client->signKidPayload($orderEndpoint, $this->getResourceAccount(), null));
}
if ('valid' !== $response['status']) {
throw new CertificateRequestFailedException('The order has not been validated');
}
$response = $client->rawRequest('POST', $response['certificate'], $client->signKidPayload($response['certificate'], $this->getResourceAccount(), null));
$responseHeaders = $response->getHeaders();
if ($returnAlternateCertificateIfAvailable && isset($responseHeaders['Link'][1])) {
$matches = [];
preg_match('/<(http.*)>;rel="alternate"/', $responseHeaders['Link'][1], $matches);
// If response headers include a valid alternate certificate link, return that certificate instead
if (isset($matches[1])) {
return $this->createCertificateResponse(
$csr,
$client->request('POST', $matches[1], $client->signKidPayload($matches[1], $this->getResourceAccount(), null), false)
);
}
}
return $this->createCertificateResponse($csr, Utils::copyToString($response->getBody()));
}
/**
* {@inheritdoc}
*/
public function requestAuthorization(string $domain): array
{
$order = $this->requestOrder([$domain]);
try {
return $order->getAuthorizationChallenges($domain);
} catch (AcmeCoreClientException $e) {
throw new ChallengeNotSupportedException();
}
}
/**
* {@inheritdoc}
*/
public function reloadAuthorization(AuthorizationChallenge $challenge): AuthorizationChallenge
{
$client = $this->getHttpClient();
$challengeUrl = $challenge->getUrl();
$response = (array) $client->request('POST', $challengeUrl, $client->signKidPayload($challengeUrl, $this->getResourceAccount(), null));
return $this->createAuthorizationChallenge($challenge->getDomain(), $response);
}
/**
* {@inheritdoc}
*/
public function challengeAuthorization(AuthorizationChallenge $challenge, int $timeout = 180): array
{
$endTime = time() + $timeout;
$client = $this->getHttpClient();
$challengeUrl = $challenge->getUrl();
$response = (array) $client->request('POST', $challengeUrl, $client->signKidPayload($challengeUrl, $this->getResourceAccount(), null));
if ('pending' === $response['status'] || 'processing' === $response['status']) {
$response = (array) $client->request('POST', $challengeUrl, $client->signKidPayload($challengeUrl, $this->getResourceAccount(), []));
}
// Waiting loop
while (time() <= $endTime && (!isset($response['status']) || 'pending' === $response['status'] || 'processing' === $response['status'])) {
sleep(1);
$response = (array) $client->request('POST', $challengeUrl, $client->signKidPayload($challengeUrl, $this->getResourceAccount(), null));
}
if (isset($response['status']) && ('pending' === $response['status'] || 'processing' === $response['status'])) {
throw new ChallengeTimedOutException($response);
}
if (!isset($response['status']) || 'valid' !== $response['status']) {
throw new ChallengeFailedException($response);
}
return $response;
}
/**
* {@inheritdoc}
*/
public function requestCertificate(string $domain, CertificateRequest $csr, int $timeout = 180, bool $returnAlternateCertificateIfAvailable = false): CertificateResponse
{
$order = $this->requestOrder(array_unique(array_merge([$domain], $csr->getDistinguishedName()->getSubjectAlternativeNames())));
return $this->finalizeOrder($order, $csr, $timeout, $returnAlternateCertificateIfAvailable);
}
/**
* {@inheritdoc}
*/
public function revokeCertificate(Certificate $certificate, RevocationReason $revocationReason = null)
{
if (!$endpoint = $this->getResourceUrl(ResourcesDirectory::REVOKE_CERT)) {
throw new CertificateRevocationException('This ACME server does not support certificate revocation.');
}
if (null === $revocationReason) {
$revocationReason = RevocationReason::createDefaultReason();
}
openssl_x509_export(openssl_x509_read($certificate->getPEM()), $formattedPem);
$formattedPem = str_ireplace('-----BEGIN CERTIFICATE-----', '', $formattedPem);
$formattedPem = str_ireplace('-----END CERTIFICATE-----', '', $formattedPem);
$client = $this->getHttpClient();
$formattedPem = $client->getBase64Encoder()->encode(base64_decode(trim($formattedPem)));
try {
$client->request(
'POST',
$endpoint,
$client->signKidPayload($endpoint, $this->getResourceAccount(), ['certificate' => $formattedPem, 'reason' => $revocationReason->getReasonType()]),
false
);
} catch (AcmeCoreServerException $e) {
throw new CertificateRevocationException($e->getMessage(), $e);
} catch (AcmeCoreClientException $e) {
throw new CertificateRevocationException($e->getMessage(), $e);
}
}
/**
* Find a resource URL from the Certificate Authority.
*/
public function getResourceUrl(string $resource): string
{
if (!$this->directory) {
$this->directory = new ResourcesDirectory(
$this->getHttpClient()->request('GET', $this->directoryUrl)
);
}
return $this->directory->getResourceUrl($resource);
}
/**
* Request a resource (URL is found using ACME server directory).
*
* @throws AcmeCoreServerException when the ACME server returns an error HTTP status code
* @throws AcmeCoreClientException when an error occured during response parsing
*
* @return array|string
*/
protected function requestResource(string $method, string $resource, array $payload, bool $returnJson = true)
{
$client = $this->getHttpClient();
$endpoint = $this->getResourceUrl($resource);
return $client->request(
$method,
$endpoint,
$client->signJwkPayload($endpoint, $payload),
$returnJson
);
}
private function createCertificateResponse(CertificateRequest $csr, string $certificate): CertificateResponse
{
$certificateHeader = '-----BEGIN CERTIFICATE-----';
$certificatesChain = null;
foreach (array_reverse(explode($certificateHeader, $certificate)) as $pem) {
if ('' !== \trim($pem)) {
$certificatesChain = new Certificate($certificateHeader.$pem, $certificatesChain);
}
}
return new CertificateResponse($csr, $certificatesChain);
}
private function getResourceAccount(): string
{
if (!$this->account) {
$payload = [
'onlyReturnExisting' => true,
];
$this->requestResource('POST', ResourcesDirectory::NEW_ACCOUNT, $payload);
$this->account = $this->getHttpClient()->getLastLocation();
}
return $this->account;
}
private function createAuthorizationChallenge($domain, array $response): AuthorizationChallenge
{
$base64encoder = $this->getHttpClient()->getBase64Encoder();
return new AuthorizationChallenge(
$domain,
$response['status'],
$response['type'],
$response['url'],
$response['token'],
$response['token'].'.'.$base64encoder->encode($this->getHttpClient()->getJWKThumbprint())
);
}
private function getHttpClient(): SecureHttpClient
{
if (!$this->initializedHttpClient) {
$this->initializedHttpClient = $this->uninitializedHttpClient;
$this->initializedHttpClient->setNonceEndpoint($this->getResourceUrl(ResourcesDirectory::NEW_NONCE));
}
return $this->initializedHttpClient;
}
}
+187
View File
@@ -0,0 +1,187 @@
<?php
/*
* This file is part of the Acme PHP project.
*
* (c) Titouan Galopin <galopintitouan@gmail.com>
*
* For the full copyright and license information, please view the LICENSE
* file that was distributed with this source code.
*/
namespace AcmePhp\Core;
use AcmePhp\Core\Exception\AcmeCoreClientException;
use AcmePhp\Core\Exception\AcmeCoreServerException;
use AcmePhp\Core\Exception\Protocol\CertificateRequestFailedException;
use AcmePhp\Core\Exception\Protocol\CertificateRequestTimedOutException;
use AcmePhp\Core\Exception\Protocol\CertificateRevocationException;
use AcmePhp\Core\Exception\Protocol\ChallengeFailedException;
use AcmePhp\Core\Exception\Protocol\ChallengeNotSupportedException;
use AcmePhp\Core\Exception\Protocol\ChallengeTimedOutException;
use AcmePhp\Core\Protocol\AuthorizationChallenge;
use AcmePhp\Core\Protocol\CertificateOrder;
use AcmePhp\Core\Protocol\ExternalAccount;
use AcmePhp\Core\Protocol\RevocationReason;
use AcmePhp\Ssl\Certificate;
use AcmePhp\Ssl\CertificateRequest;
use AcmePhp\Ssl\CertificateResponse;
/**
* ACME protocol client interface.
*
* @author Titouan Galopin <galopintitouan@gmail.com>
*/
interface AcmeClientInterface
{
/**
* Register the local account KeyPair in the Certificate Authority.
*
* @param string|null $email an optionnal e-mail to associate with the account
* @param ExternalAccount|null $externalAccount an optionnal External Account to use for External Account Binding
*
* @throws AcmeCoreServerException when the ACME server returns an error HTTP status code
* (the exception will be more specific if detail is provided)
* @throws AcmeCoreClientException when an error occured during response parsing
*
* @return array the Certificate Authority response decoded from JSON into an array
*/
public function registerAccount(string $email = null, ExternalAccount $externalAccount = null): array;
/**
* Request authorization challenge data for a list of domains.
*
* An AuthorizationChallenge is an association between a URI, a token and a payload.
* The Certificate Authority will create this challenge data and you will then have
* to expose the payload for the verification (see challengeAuthorization).
*
* @param string[] $domains the domains to challenge
*
* @throws AcmeCoreServerException when the ACME server returns an error HTTP status code
* (the exception will be more specific if detail is provided)
* @throws AcmeCoreClientException when an error occured during response parsing
* @throws ChallengeNotSupportedException when the HTTP challenge is not supported by the server
*
* @return CertificateOrder the Order returned by the Certificate Authority
*/
public function requestOrder(array $domains): CertificateOrder;
/**
* Request the current status of a certificate order.
*/
public function reloadOrder(CertificateOrder $order): CertificateOrder;
/**
* Request a certificate for the given domain.
*
* This method should be called only if a previous authorization challenge has
* been successful for the asked domain.
*
* WARNING : This method SHOULD NOT BE USED in a web action. It will
* wait for the Certificate Authority to validate the certificate and
* this operation could be long.
*
* @param CertificateOrder $order the Order returned by the Certificate Authority
* @param CertificateRequest $csr the Certificate Signing Request (informations for the certificate)
* @param int $timeout the timeout period
* @param bool $returnAlternateCertificateIfAvailable whether the alternate certificate provided by
* the CA should be returned instead of the main one.
* This is especially useful following
* https://letsencrypt.org/2019/04/15/transitioning-to-isrg-root.html.
*
* @throws AcmeCoreServerException when the ACME server returns an error HTTP status code
* (the exception will be more specific if detail is provided)
* @throws AcmeCoreClientException when an error occured during response parsing
* @throws CertificateRequestFailedException when the certificate request failed
* @throws CertificateRequestTimedOutException when the certificate request timed out
*
* @return CertificateResponse the certificate data to save it somewhere you want
*/
public function finalizeOrder(CertificateOrder $order, CertificateRequest $csr, int $timeout = 180, bool $returnAlternateCertificateIfAvailable = false): CertificateResponse;
/**
* Request authorization challenge data for a given domain.
*
* An AuthorizationChallenge is an association between a URI, a token and a payload.
* The Certificate Authority will create this challenge data and you will then have
* to expose the payload for the verification (see challengeAuthorization).
*
* @param string $domain the domain to challenge
*
* @throws AcmeCoreServerException when the ACME server returns an error HTTP status code
* (the exception will be more specific if detail is provided)
* @throws AcmeCoreClientException when an error occured during response parsing
* @throws ChallengeNotSupportedException when the HTTP challenge is not supported by the server
*
* @return AuthorizationChallenge[] the list of challenges data returned by the Certificate Authority
*/
public function requestAuthorization(string $domain): array;
/**
* Request the current status of an authorization challenge.
*
* @param AuthorizationChallenge $challenge The challenge to request
*
* @return AuthorizationChallenge A new instance of the challenge
*/
public function reloadAuthorization(AuthorizationChallenge $challenge): AuthorizationChallenge;
/**
* Ask the Certificate Authority to challenge a given authorization.
*
* This check will generally consists of requesting over HTTP the domain
* at a specific URL. This URL should return the raw payload generated
* by requestAuthorization.
*
* WARNING : This method SHOULD NOT BE USED in a web action. It will
* wait for the Certificate Authority to validate the challenge and this
* operation could be long.
*
* @param AuthorizationChallenge $challenge the challenge data to check
* @param int $timeout the timeout period
*
* @throws AcmeCoreServerException when the ACME server returns an error HTTP status code
* (the exception will be more specific if detail is provided)
* @throws AcmeCoreClientException when an error occured during response parsing
* @throws ChallengeTimedOutException when the challenge timed out
* @throws ChallengeFailedException when the challenge failed
*
* @return array the validate challenge response
*/
public function challengeAuthorization(AuthorizationChallenge $challenge, int $timeout = 180): array;
/**
* Request a certificate for the given domain.
*
* This method should be called only if a previous authorization challenge has
* been successful for the asked domain.
*
* WARNING : This method SHOULD NOT BE USED in a web action. It will
* wait for the Certificate Authority to validate the certificate and
* this operation could be long.
*
* @param string $domain the domain to request a certificate for
* @param CertificateRequest $csr the Certificate Signing Request (informations for the certificate)
* @param int $timeout the timeout period
* @param bool $returnAlternateCertificateIfAvailable whether the alternate certificate provided by
* the CA should be returned instead of the main one.
* This is especially useful following
* https://letsencrypt.org/2019/04/15/transitioning-to-isrg-root.html.
*
* @throws AcmeCoreServerException when the ACME server returns an error HTTP status code
* (the exception will be more specific if detail is provided)
* @throws AcmeCoreClientException when an error occured during response parsing
* @throws CertificateRequestFailedException when the certificate request failed
* @throws CertificateRequestTimedOutException when the certificate request timed out
*
* @return CertificateResponse the certificate data to save it somewhere you want
*/
public function requestCertificate(string $domain, CertificateRequest $csr, int $timeout = 180, bool $returnAlternateCertificateIfAvailable = false): CertificateResponse;
/**
* Revoke a given certificate from the Certificate Authority.
*
* @throws CertificateRevocationException
*/
public function revokeCertificate(Certificate $certificate, RevocationReason $revocationReason = null);
}
+62
View File
@@ -0,0 +1,62 @@
<?php
/*
* This file is part of the Acme PHP project.
*
* (c) Titouan Galopin <galopintitouan@gmail.com>
*
* For the full copyright and license information, please view the LICENSE
* file that was distributed with this source code.
*/
namespace AcmePhp\Core\Challenge;
use AcmePhp\Core\Exception\Protocol\ChallengeNotSupportedException;
use AcmePhp\Core\Protocol\AuthorizationChallenge;
/**
* A strategy ACME validator.
*
* @author Jérémy Derussé <jeremy@derusse.com>
*/
class ChainValidator implements ValidatorInterface
{
/** @var ValidatorInterface[] */
private $validators;
/**
* @param ValidatorInterface[] $validators
*/
public function __construct(array $validators)
{
$this->validators = $validators;
}
/**
* {@inheritdoc}
*/
public function supports(AuthorizationChallenge $authorizationChallenge, SolverInterface $solver): bool
{
foreach ($this->validators as $validator) {
if ($validator->supports($authorizationChallenge, $solver)) {
return true;
}
}
return false;
}
/**
* {@inheritdoc}
*/
public function isValid(AuthorizationChallenge $authorizationChallenge, SolverInterface $solver): bool
{
foreach ($this->validators as $validator) {
if ($validator->supports($authorizationChallenge, $solver)) {
return $validator->isValid($authorizationChallenge, $solver);
}
}
throw new ChallengeNotSupportedException();
}
}
@@ -0,0 +1,25 @@
<?php
/*
* This file is part of the Acme PHP project.
*
* (c) Titouan Galopin <galopintitouan@gmail.com>
*
* For the full copyright and license information, please view the LICENSE
* file that was distributed with this source code.
*/
namespace AcmePhp\Core\Challenge;
/**
* ACME challenge solver.
*
* @author Jérémy Derussé <jeremy@derusse.com>
*/
interface ConfigurableServiceInterface
{
/**
* Configure the service with a set of configuration.
*/
public function configure(array $config);
}
@@ -0,0 +1,47 @@
<?php
/*
* This file is part of the Acme PHP project.
*
* (c) Titouan Galopin <galopintitouan@gmail.com>
*
* For the full copyright and license information, please view the LICENSE
* file that was distributed with this source code.
*/
namespace AcmePhp\Core\Challenge\Dns;
use AcmePhp\Core\Http\Base64SafeEncoder;
use AcmePhp\Core\Protocol\AuthorizationChallenge;
/**
* Extract data needed to solve DNS challenges.
*
* @author Jérémy Derussé <jeremy@derusse.com>
*/
class DnsDataExtractor
{
/** @var Base64SafeEncoder */
private $encoder;
public function __construct(Base64SafeEncoder $encoder = null)
{
$this->encoder = $encoder ?: new Base64SafeEncoder();
}
/**
* Retrieves the name of the TXT record to register.
*/
public function getRecordName(AuthorizationChallenge $authorizationChallenge): string
{
return sprintf('_acme-challenge.%s.', $authorizationChallenge->getDomain());
}
/**
* Retrieves the value of the TXT record to register.
*/
public function getRecordValue(AuthorizationChallenge $authorizationChallenge): string
{
return $this->encoder->encode(hash('sha256', $authorizationChallenge->getPayload(), true));
}
}
@@ -0,0 +1,30 @@
<?php
/*
* This file is part of the Acme PHP project.
*
* (c) Titouan Galopin <galopintitouan@gmail.com>
*
* For the full copyright and license information, please view the LICENSE
* file that was distributed with this source code.
*/
namespace AcmePhp\Core\Challenge\Dns;
/**
* Resolves DNS.
*
* @author Jérémy Derussé <jeremy@derusse.com>
*/
interface DnsResolverInterface
{
/**
* Return whether or not the Resolver is supported.
*/
public static function isSupported(): bool;
/**
* Retrieves the list of TXT entries for the given domain.
*/
public function getTxtEntries(string $domain): array;
}
@@ -0,0 +1,68 @@
<?php
/*
* This file is part of the Acme PHP project.
*
* (c) Titouan Galopin <galopintitouan@gmail.com>
*
* For the full copyright and license information, please view the LICENSE
* file that was distributed with this source code.
*/
namespace AcmePhp\Core\Challenge\Dns;
use AcmePhp\Core\Challenge\SolverInterface;
use AcmePhp\Core\Challenge\ValidatorInterface;
use AcmePhp\Core\Exception\AcmeDnsResolutionException;
use AcmePhp\Core\Protocol\AuthorizationChallenge;
/**
* Validator for DNS challenges.
*
* @author Jérémy Derussé <jeremy@derusse.com>
*/
class DnsValidator implements ValidatorInterface
{
/**
* @var DnsDataExtractor
*/
private $extractor;
/**
* @var DnsResolverInterface
*/
private $dnsResolver;
public function __construct(DnsDataExtractor $extractor = null, DnsResolverInterface $dnsResolver = null)
{
$this->extractor = $extractor ?: new DnsDataExtractor();
$this->dnsResolver = $dnsResolver;
if (!$this->dnsResolver) {
$this->dnsResolver = LibDnsResolver::isSupported() ? new LibDnsResolver() : new SimpleDnsResolver();
}
}
/**
* {@inheritdoc}
*/
public function supports(AuthorizationChallenge $authorizationChallenge, SolverInterface $solver): bool
{
return 'dns-01' === $authorizationChallenge->getType();
}
/**
* {@inheritdoc}
*/
public function isValid(AuthorizationChallenge $authorizationChallenge, SolverInterface $solver): bool
{
$recordName = $this->extractor->getRecordName($authorizationChallenge);
$recordValue = $this->extractor->getRecordValue($authorizationChallenge);
try {
return \in_array($recordValue, $this->dnsResolver->getTxtEntries($recordName), false);
} catch (AcmeDnsResolutionException $e) {
return false;
}
}
}
+152
View File
@@ -0,0 +1,152 @@
<?php
/*
* This file is part of the Acme PHP project.
*
* (c) Titouan Galopin <galopintitouan@gmail.com>
*
* For the full copyright and license information, please view the LICENSE
* file that was distributed with this source code.
*/
namespace AcmePhp\Core\Challenge\Dns;
use AcmePhp\Core\Challenge\ConfigurableServiceInterface;
use AcmePhp\Core\Challenge\MultipleChallengesSolverInterface;
use AcmePhp\Core\Protocol\AuthorizationChallenge;
use GuzzleHttp\Client;
use GuzzleHttp\ClientInterface;
use Psr\Log\LoggerAwareTrait;
use Psr\Log\NullLogger;
use Webmozart\Assert\Assert;
/**
* ACME DNS solver with automate configuration of a Gandi.Net.
*
* @author Alexander Obuhovich <aik.bold@gmail.com>
*/
class GandiSolver implements MultipleChallengesSolverInterface, ConfigurableServiceInterface
{
use LoggerAwareTrait;
/**
* @var DnsDataExtractor
*/
private $extractor;
/**
* @var ClientInterface
*/
private $client;
/**
* @var array
*/
private $cacheZones;
/**
* @var string
*/
private $apiKey;
public function __construct(DnsDataExtractor $extractor = null, ClientInterface $client = null)
{
$this->extractor = $extractor ?: new DnsDataExtractor();
$this->client = $client ?: new Client();
$this->logger = new NullLogger();
}
/**
* Configure the service with a set of configuration.
*/
public function configure(array $config)
{
$this->apiKey = $config['api_key'];
}
/**
* {@inheritdoc}
*/
public function supports(AuthorizationChallenge $authorizationChallenge): bool
{
return 'dns-01' === $authorizationChallenge->getType();
}
/**
* {@inheritdoc}
*/
public function solve(AuthorizationChallenge $authorizationChallenge)
{
return $this->solveAll([$authorizationChallenge]);
}
/**
* {@inheritdoc}
*/
public function solveAll(array $authorizationChallenges)
{
Assert::allIsInstanceOf($authorizationChallenges, AuthorizationChallenge::class);
foreach ($authorizationChallenges as $authorizationChallenge) {
$topLevelDomain = $this->getTopLevelDomain($authorizationChallenge->getDomain());
$recordName = $this->extractor->getRecordName($authorizationChallenge);
$recordValue = $this->extractor->getRecordValue($authorizationChallenge);
$subDomain = \str_replace('.'.$topLevelDomain.'.', '', $recordName);
$this->client->request(
'PUT',
'https://dns.api.gandi.net/api/v5/domains/'.$topLevelDomain.'/records/'.$subDomain.'/TXT',
[
'headers' => [
'X-Api-Key' => $this->apiKey,
],
'json' => [
'rrset_type' => 'TXT',
'rrset_ttl' => 600,
'rrset_name' => $subDomain,
'rrset_values' => [$recordValue],
],
]
);
}
}
/**
* {@inheritdoc}
*/
public function cleanup(AuthorizationChallenge $authorizationChallenge)
{
return $this->cleanupAll([$authorizationChallenge]);
}
/**
* {@inheritdoc}
*/
public function cleanupAll(array $authorizationChallenges)
{
Assert::allIsInstanceOf($authorizationChallenges, AuthorizationChallenge::class);
foreach ($authorizationChallenges as $authorizationChallenge) {
$topLevelDomain = $this->getTopLevelDomain($authorizationChallenge->getDomain());
$recordName = $this->extractor->getRecordName($authorizationChallenge);
$subDomain = \str_replace('.'.$topLevelDomain.'.', '', $recordName);
$this->client->request(
'DELETE',
'https://dns.api.gandi.net/api/v5/domains/'.$topLevelDomain.'/records/'.$subDomain.'/TXT',
[
'headers' => [
'X-Api-Key' => $this->apiKey,
],
]
);
}
}
protected function getTopLevelDomain(string $domain): string
{
return \implode('.', \array_slice(\explode('.', $domain), -2));
}
}
@@ -0,0 +1,200 @@
<?php
/*
* This file is part of the Acme PHP project.
*
* (c) Titouan Galopin <galopintitouan@gmail.com>
*
* For the full copyright and license information, please view the LICENSE
* file that was distributed with this source code.
*/
namespace AcmePhp\Core\Challenge\Dns;
use AcmePhp\Core\Exception\AcmeDnsResolutionException;
use LibDNS\Decoder\Decoder;
use LibDNS\Decoder\DecoderFactory;
use LibDNS\Encoder\Encoder;
use LibDNS\Encoder\EncoderFactory;
use LibDNS\Messages\MessageFactory;
use LibDNS\Messages\MessageTypes;
use LibDNS\Records\QuestionFactory;
use LibDNS\Records\ResourceTypes;
use Psr\Log\LoggerAwareTrait;
use Psr\Log\NullLogger;
/**
* Resolves DNS with LibDNS (pass over internal DNS cache and check several nameservers).
*
* @author Jérémy Derussé <jeremy@derusse.com>
*/
class LibDnsResolver implements DnsResolverInterface
{
use LoggerAwareTrait;
/**
* @var QuestionFactory
*/
private $questionFactory;
/**
* @var MessageFactory
*/
private $messageFactory;
/**
* @var Encoder
*/
private $encoder;
/**
* @var Decoder
*/
private $decoder;
/**
* @var string
*/
private $nameServer;
public function __construct(
QuestionFactory $questionFactory = null,
MessageFactory $messageFactory = null,
Encoder $encoder = null,
Decoder $decoder = null,
$nameServer = '8.8.8.8'
) {
$this->questionFactory = $questionFactory ?: new QuestionFactory();
$this->messageFactory = $messageFactory ?: new MessageFactory();
$this->encoder = $encoder ?: (new EncoderFactory())->create();
$this->decoder = $decoder ?: (new DecoderFactory())->create();
$this->nameServer = $nameServer;
$this->logger = new NullLogger();
}
/**
* @{@inheritdoc}
*/
public static function isSupported(): bool
{
return class_exists(ResourceTypes::class);
}
/**
* @{@inheritdoc}
*/
public function getTxtEntries($domain): array
{
$domain = rtrim($domain, '.');
$nameServers = $this->getNameServers($domain);
$this->logger->debug('Fetched TXT records for domain', ['nsDomain' => $domain, 'servers' => $nameServers]);
$identicalEntries = [];
foreach ($nameServers as $nameServer) {
$ipNameServer = gethostbynamel($nameServer);
if (empty($ipNameServer)) {
throw new AcmeDnsResolutionException(sprintf('Unable to find domain %s on nameserver %s', $domain, $nameServer));
}
try {
$response = $this->request($domain, ResourceTypes::TXT, $ipNameServer[0]);
} catch (\Exception $e) {
throw new AcmeDnsResolutionException(sprintf('Unable to find domain %s on nameserver %s', $domain, $nameServer));
}
$entries = [];
foreach ($response->getAnswerRecords() as $record) {
foreach ($record->getData() as $recordData) {
$entries[] = (string) $recordData;
}
}
$identicalEntries[json_encode($entries)][] = $nameServer;
}
$this->logger->info('DNS records fetched', ['mapping' => $identicalEntries]);
if (1 !== \count($identicalEntries)) {
throw new AcmeDnsResolutionException('Dns not fully propagated');
}
return json_decode(key($identicalEntries));
}
private function getNameServers($domain)
{
if ('' === $domain) {
return [$this->nameServer];
}
$parentNameServers = $this->getNameServers(implode('.', \array_slice(explode('.', $domain), 1)));
$itemNameServers = [];
$this->logger->debug('Fetched NS in charge of domain', ['nsDomain' => $domain, 'servers' => $parentNameServers]);
foreach ($parentNameServers as $parentNameServer) {
$ipNameServer = gethostbynamel($parentNameServer);
if (empty($ipNameServer)) {
continue;
}
try {
$response = $this->request(
$domain,
ResourceTypes::NS,
$ipNameServer[0]
);
} catch (\Exception $e) {
// ignore errors
continue;
}
foreach ($response->getAnswerRecords() as $record) {
try {
$field = $record->getData()->getFieldByName('nsdname');
$itemNameServers[] = $field->getValue();
} catch (\OutOfBoundsException $e) {
}
}
foreach ($response->getAuthorityRecords() as $record) {
try {
$field = $record->getData()->getFieldByName('nsdname');
$itemNameServers[] = $field->getValue();
} catch (\OutOfBoundsException $e) {
}
}
}
$itemNameServers = array_unique($itemNameServers);
if (empty($itemNameServers)) {
return $parentNameServers;
}
return $itemNameServers;
}
private function request($domain, $type, $nameServer)
{
$question = $this->questionFactory->create($type);
$question->setName($domain);
// Create request message
$request = $this->messageFactory->create(MessageTypes::QUERY);
$request->getQuestionRecords()->add($question);
$request->isRecursionDesired(true);
// Send request
$socket = stream_socket_client(sprintf('udp://'.$nameServer.':53'));
stream_socket_sendto($socket, $this->encoder->encode($request));
$r = [$socket];
$w = $e = [];
if (!stream_select($r, $w, $e, 3)) {
throw new AcmeDnsResolutionException(sprintf('Timeout reached when requesting ServerName %s', $nameServer));
}
// Decode response message
try {
$response = $this->decoder->decode(fread($socket, 1 << 20));
} catch (\Exception $e) {
throw new AcmeDnsResolutionException('Failed to decode server response', $e);
}
if (0 !== $response->getResponseCode()) {
throw new AcmeDnsResolutionException(sprintf('ServerName respond with error code "%d"', $response->getResponseCode()));
}
return $response;
}
}
@@ -0,0 +1,295 @@
<?php
/*
* This file is part of the Acme PHP project.
*
* (c) Titouan Galopin <galopintitouan@gmail.com>
*
* For the full copyright and license information, please view the LICENSE
* file that was distributed with this source code.
*/
namespace AcmePhp\Core\Challenge\Dns;
use AcmePhp\Core\Challenge\MultipleChallengesSolverInterface;
use AcmePhp\Core\Exception\Protocol\ChallengeFailedException;
use AcmePhp\Core\Protocol\AuthorizationChallenge;
use Aws\Route53\Route53Client;
use Psr\Log\LoggerAwareTrait;
use Psr\Log\NullLogger;
use Webmozart\Assert\Assert;
/**
* ACME DNS solver with automate configuration of a AWS route53.
*
* @author Jérémy Derussé <jeremy@derusse.com>
*/
class Route53Solver implements MultipleChallengesSolverInterface
{
use LoggerAwareTrait;
/**
* @var DnsDataExtractor
*/
private $extractor;
/**
* @var Route53Client
*/
private $client;
/**
* @var array
*/
private $cacheZones;
public function __construct(DnsDataExtractor $extractor = null, Route53Client $client = null)
{
$this->extractor = $extractor ?: new DnsDataExtractor();
$this->client = $client ?: new Route53Client([]);
$this->logger = new NullLogger();
}
/**
* {@inheritdoc}
*/
public function supports(AuthorizationChallenge $authorizationChallenge): bool
{
return 'dns-01' === $authorizationChallenge->getType();
}
/**
* {@inheritdoc}
*/
public function solve(AuthorizationChallenge $authorizationChallenge)
{
return $this->solveAll([$authorizationChallenge]);
}
/**
* {@inheritdoc}
*/
public function solveAll(array $authorizationChallenges)
{
Assert::allIsInstanceOf($authorizationChallenges, AuthorizationChallenge::class);
$changesPerZone = [];
$authorizationChallengesPerDomain = $this->groupAuthorizationChallengesPerDomain($authorizationChallenges);
foreach ($authorizationChallengesPerDomain as $domain => $authorizationChallengesForDomain) {
$zone = $this->getZone($authorizationChallengesForDomain[0]->getDomain());
$authorizationChallengesPerRecordName = $this->groupAuthorizationChallengesPerRecordName($authorizationChallengesForDomain);
foreach ($authorizationChallengesPerRecordName as $recordName => $authorizationChallengesForRecordName) {
$challengeValues = array_unique(array_map([$this->extractor, 'getRecordValue'], $authorizationChallengesForRecordName));
$recordIndex = $this->getPreviousRecordIndex($zone['Id'], $recordName);
if (0 === \count(array_diff($challengeValues, array_keys($recordIndex)))) {
$this->logger->debug('Record already defined', ['recordName' => $recordName]);
continue;
}
foreach ($challengeValues as $recordValue) {
$recordIndex[$recordValue] = time();
}
$changesPerZone[$zone['Id']][] = $this->getSaveRecordQuery($recordName, $recordIndex);
}
}
$records = [];
foreach ($changesPerZone as $zoneId => $changes) {
$this->logger->info('Updating route 53 DNS', ['zone' => $zoneId]);
$records[$zoneId] = $this->client->changeResourceRecordSets(
[
'ChangeBatch' => [
'Changes' => $changes,
],
'HostedZoneId' => $zoneId,
]
);
}
foreach ($records as $zoneId => $record) {
$this->logger->info('Waiting for Route 53 changes', ['zone' => $zoneId]);
$this->client->waitUntil('ResourceRecordSetsChanged', ['Id' => $record['ChangeInfo']['Id']]);
}
}
/**
* {@inheritdoc}
*/
public function cleanup(AuthorizationChallenge $authorizationChallenge)
{
return $this->cleanupAll([$authorizationChallenge]);
}
/**
* {@inheritdoc}
*/
public function cleanupAll(array $authorizationChallenges)
{
Assert::allIsInstanceOf($authorizationChallenges, AuthorizationChallenge::class);
$changesPerZone = [];
$authorizationChallengesPerDomain = $this->groupAuthorizationChallengesPerDomain($authorizationChallenges);
foreach ($authorizationChallengesPerDomain as $domain => $authorizationChallengesForDomain) {
$zone = $this->getZone($authorizationChallengesForDomain[0]->getDomain());
$authorizationChallengesPerRecordName = $this->groupAuthorizationChallengesPerRecordName($authorizationChallengesForDomain);
foreach ($authorizationChallengesPerRecordName as $recordName => $authorizationChallengesForRecordName) {
$challengeValues = array_unique(array_map([$this->extractor, 'getRecordValue'], $authorizationChallengesForRecordName));
$recordIndex = $this->getPreviousRecordIndex($zone['Id'], $recordName);
foreach ($challengeValues as $recordValue) {
unset($recordIndex[$recordValue]);
}
$changesPerZone[$zone['Id']][] = $this->getSaveRecordQuery($recordName, $recordIndex);
}
}
foreach ($changesPerZone as $zoneId => $changes) {
$this->logger->info('Updating route 53 DNS', ['zone' => $zoneId]);
$this->client->changeResourceRecordSets(
[
'ChangeBatch' => [
'Changes' => $changes,
],
'HostedZoneId' => $zoneId,
]
);
}
}
private function getPreviousRecordIndex($zoneId, $recordName)
{
$previousRecordSets = $this->client->listResourceRecordSets([
'HostedZoneId' => $zoneId,
'StartRecordName' => $recordName,
'StartRecordType' => 'TXT',
]);
$recordSets = array_filter(
$previousRecordSets['ResourceRecordSets'],
function ($recordSet) use ($recordName) {
return $recordSet['Name'] === $recordName && 'TXT' === $recordSet['Type'];
}
);
$recordIndex = [];
foreach ($recordSets as $previousRecordSet) {
$previousTxt = array_map(function ($resourceRecord) {
return stripslashes(trim($resourceRecord['Value'], '"'));
}, $previousRecordSet['ResourceRecords']);
// Search the special Index
foreach ($previousTxt as $index => $recordValue) {
if (null !== $previousIndex = json_decode($recordValue, true)) {
$recordIndex = $previousIndex;
unset($previousTxt[$index]);
break;
}
}
// Set default value
foreach ($previousTxt as $recordValue) {
if (!isset($recordIndex[$recordValue])) {
$recordIndex[$recordValue] = time();
}
}
}
return $recordIndex;
}
private function getSaveRecordQuery($recordName, array $recordIndex)
{
//remove old indexes
$limitTime = time() - 86400;
foreach ($recordIndex as $recordValue => $time) {
if ($time < $limitTime) {
unset($recordIndex[$recordValue]);
}
}
$recordValues = array_keys($recordIndex);
$recordValues[] = json_encode($recordIndex);
return [
'Action' => 'UPSERT',
'ResourceRecordSet' => [
'Name' => $recordName,
'ResourceRecords' => array_map(function ($recordValue) {
return [
'Value' => sprintf('"%s"', addslashes($recordValue)),
];
}, $recordValues),
'TTL' => 5,
'Type' => 'TXT',
],
];
}
/**
* @param AuthorizationChallenge[] $authorizationChallenges
*
* @return AuthorizationChallenge[][]
*/
private function groupAuthorizationChallengesPerDomain(array $authorizationChallenges)
{
$groups = [];
foreach ($authorizationChallenges as $authorizationChallenge) {
$groups[$authorizationChallenge->getDomain()][] = $authorizationChallenge;
}
return $groups;
}
/**
* @param AuthorizationChallenge[] $authorizationChallenges
*
* @return AuthorizationChallenge[][]
*/
private function groupAuthorizationChallengesPerRecordName(array $authorizationChallenges)
{
$groups = [];
foreach ($authorizationChallenges as $authorizationChallenge) {
$groups[$this->extractor->getRecordName($authorizationChallenge)][] = $authorizationChallenge;
}
return $groups;
}
private function getZone($domain)
{
$domainParts = explode('.', $domain);
$domains = array_reverse(array_map(
function ($index) use ($domainParts) {
return implode('.', \array_slice($domainParts, \count($domainParts) - $index));
},
range(0, \count($domainParts))
));
$zones = $this->getZones();
foreach ($domains as $cursorDomain) {
if (isset($zones[$cursorDomain.'.'])) {
return $zones[$cursorDomain.'.'];
}
}
throw new ChallengeFailedException(sprintf('Unable to find a zone for the domain "%s"', $domain));
}
private function getZones()
{
if (null !== $this->cacheZones) {
return $this->cacheZones;
}
$zones = [];
$args = [];
do {
$resp = $this->client->listHostedZones($args);
$zones = array_merge($zones, $resp['HostedZones']);
$args = ['Marker' => $resp['NextMarker']];
} while ($resp['IsTruncated']);
$this->cacheZones = array_column($zones, null, 'Name');
return $this->cacheZones;
}
}
@@ -0,0 +1,43 @@
<?php
/*
* This file is part of the Acme PHP project.
*
* (c) Titouan Galopin <galopintitouan@gmail.com>
*
* For the full copyright and license information, please view the LICENSE
* file that was distributed with this source code.
*/
namespace AcmePhp\Core\Challenge\Dns;
/**
* Resolves DNS through dns_get_record.
*
* @author Jérémy Derussé <jeremy@derusse.com>
*/
class SimpleDnsResolver implements DnsResolverInterface
{
/**
* @{@inheritdoc}
*/
public static function isSupported(): bool
{
return \function_exists('dns_get_record');
}
/**
* @{@inheritdoc}
*/
public function getTxtEntries($domain): array
{
$entries = [];
foreach (dns_get_record($domain, DNS_TXT) as $record) {
$entries = array_merge($entries, $record['entries']);
}
sort($entries);
return array_unique($entries);
}
}
@@ -0,0 +1,100 @@
<?php
/*
* This file is part of the Acme PHP project.
*
* (c) Titouan Galopin <galopintitouan@gmail.com>
*
* For the full copyright and license information, please view the LICENSE
* file that was distributed with this source code.
*/
namespace AcmePhp\Core\Challenge\Dns;
use AcmePhp\Core\Challenge\SolverInterface;
use AcmePhp\Core\Protocol\AuthorizationChallenge;
use Symfony\Component\Console\Output\NullOutput;
use Symfony\Component\Console\Output\OutputInterface;
/**
* ACME DNS solver with manual intervention.
*
* @author Jérémy Derussé <jeremy@derusse.com>
*/
class SimpleDnsSolver implements SolverInterface
{
/**
* @var DnsDataExtractor
*/
private $extractor;
/**
* @var OutputInterface
*/
protected $output;
/**
* @param DnsDataExtractor $extractor
* @param OutputInterface $output
*/
public function __construct(DnsDataExtractor $extractor = null, OutputInterface $output = null)
{
$this->extractor = $extractor ?: new DnsDataExtractor();
$this->output = $output ?: new NullOutput();
}
/**
* {@inheritdoc}
*/
public function supports(AuthorizationChallenge $authorizationChallenge): bool
{
return 'dns-01' === $authorizationChallenge->getType();
}
/**
* {@inheritdoc}
*/
public function solve(AuthorizationChallenge $authorizationChallenge)
{
$recordName = $this->extractor->getRecordName($authorizationChallenge);
$recordValue = $this->extractor->getRecordValue($authorizationChallenge);
$this->output->writeln(
sprintf(
<<<'EOF'
Add the following TXT record to your DNS zone
Domain: %s
TXT value: %s
<comment>Wait for the propagation before moving to the next step</comment>
Tips: Use the following command to check the propagation
host -t TXT %s
EOF
,
$recordName,
$recordValue,
$recordName
)
);
}
/**
* {@inheritdoc}
*/
public function cleanup(AuthorizationChallenge $authorizationChallenge)
{
$recordName = $this->extractor->getRecordName($authorizationChallenge);
$this->output->writeln(
sprintf(
<<<'EOF'
You can now cleanup your DNS by removing the domain <comment>_acme-challenge.%s.</comment>
EOF
,
$recordName
)
);
}
}
@@ -0,0 +1,90 @@
<?php
/*
* This file is part of the Acme PHP project.
*
* (c) Titouan Galopin <galopintitouan@gmail.com>
*
* For the full copyright and license information, please view the LICENSE
* file that was distributed with this source code.
*/
namespace AcmePhp\Core\Challenge\Http;
use AcmePhp\Core\Challenge\ConfigurableServiceInterface;
use AcmePhp\Core\Challenge\SolverInterface;
use AcmePhp\Core\Filesystem\Adapter\NullAdapter;
use AcmePhp\Core\Filesystem\FilesystemFactoryInterface;
use AcmePhp\Core\Filesystem\FilesystemInterface;
use AcmePhp\Core\Protocol\AuthorizationChallenge;
use Psr\Container\ContainerInterface;
use Symfony\Component\DependencyInjection\ServiceLocator;
use Webmozart\Assert\Assert;
/**
* ACME HTTP solver through ftp upload.
*
* @author Jérémy Derussé <jeremy@derusse.com>
*/
class FilesystemSolver implements SolverInterface, ConfigurableServiceInterface
{
/**
* @var ContainerInterface
*/
private $filesystemFactoryLocator;
/**
* @var FilesystemInterface
*/
private $filesystem;
/**
* @var HttpDataExtractor
*/
private $extractor;
public function __construct(ContainerInterface $filesystemFactoryLocator = null, HttpDataExtractor $extractor = null)
{
$this->filesystemFactoryLocator = $filesystemFactoryLocator ?: new ServiceLocator([]);
$this->extractor = $extractor ?: new HttpDataExtractor();
$this->filesystem = new NullAdapter();
}
public function configure(array $config)
{
Assert::keyExists($config, 'adapter', 'configure::$config expected an array with the key %s.');
/** @var FilesystemFactoryInterface $factory */
$factory = $this->filesystemFactoryLocator->get($config['adapter']);
$this->filesystem = $factory->create($config);
}
/**
* {@inheritdoc}
*/
public function supports(AuthorizationChallenge $authorizationChallenge): bool
{
return 'http-01' === $authorizationChallenge->getType();
}
/**
* {@inheritdoc}
*/
public function solve(AuthorizationChallenge $authorizationChallenge)
{
$checkPath = $this->extractor->getCheckPath($authorizationChallenge);
$checkContent = $this->extractor->getCheckContent($authorizationChallenge);
$this->filesystem->write($checkPath, $checkContent);
}
/**
* {@inheritdoc}
*/
public function cleanup(AuthorizationChallenge $authorizationChallenge)
{
$checkPath = $this->extractor->getCheckPath($authorizationChallenge);
$this->filesystem->delete($checkPath);
}
}
@@ -0,0 +1,53 @@
<?php
/*
* This file is part of the Acme PHP project.
*
* (c) Titouan Galopin <galopintitouan@gmail.com>
*
* For the full copyright and license information, please view the LICENSE
* file that was distributed with this source code.
*/
namespace AcmePhp\Core\Challenge\Http;
use AcmePhp\Core\Protocol\AuthorizationChallenge;
/**
* Extract data needed to solve HTTP challenges.
*
* @author Jérémy Derussé <jeremy@derusse.com>
*/
class HttpDataExtractor
{
/**
* Retrieves the absolute URL called by the CA.
*/
public function getCheckUrl(AuthorizationChallenge $authorizationChallenge): string
{
return sprintf(
'http://%s%s',
$authorizationChallenge->getDomain(),
$this->getCheckPath($authorizationChallenge)
);
}
/**
* Retrieves the absolute path called by the CA.
*/
public function getCheckPath(AuthorizationChallenge $authorizationChallenge): string
{
return sprintf(
'/.well-known/acme-challenge/%s',
$authorizationChallenge->getToken()
);
}
/**
* Retrieves the content that should be returned in the response.
*/
public function getCheckContent(AuthorizationChallenge $authorizationChallenge): string
{
return $authorizationChallenge->getPayload();
}
}
@@ -0,0 +1,65 @@
<?php
/*
* This file is part of the Acme PHP project.
*
* (c) Titouan Galopin <galopintitouan@gmail.com>
*
* For the full copyright and license information, please view the LICENSE
* file that was distributed with this source code.
*/
namespace AcmePhp\Core\Challenge\Http;
use AcmePhp\Core\Challenge\SolverInterface;
use AcmePhp\Core\Challenge\ValidatorInterface;
use AcmePhp\Core\Protocol\AuthorizationChallenge;
use GuzzleHttp\Client;
use GuzzleHttp\Exception\ClientException;
/**
* Validator for HTTP challenges.
*
* @author Jérémy Derussé <jeremy@derusse.com>
*/
class HttpValidator implements ValidatorInterface
{
/**
* @var HttpDataExtractor
*/
private $extractor;
/**
* @var Client
*/
private $client;
public function __construct(HttpDataExtractor $extractor = null, Client $client = null)
{
$this->extractor = $extractor ?: new HttpDataExtractor();
$this->client = $client ?: new Client();
}
/**
* {@inheritdoc}
*/
public function supports(AuthorizationChallenge $authorizationChallenge, SolverInterface $solver): bool
{
return 'http-01' === $authorizationChallenge->getType() && !$solver instanceof MockServerHttpSolver;
}
/**
* {@inheritdoc}
*/
public function isValid(AuthorizationChallenge $authorizationChallenge, SolverInterface $solver): bool
{
$checkUrl = $this->extractor->getCheckUrl($authorizationChallenge);
$checkContent = $this->extractor->getCheckContent($authorizationChallenge);
try {
return $checkContent === trim($this->client->get($checkUrl, ['verify' => false])->getBody()->getContents());
} catch (ClientException $e) {
return false;
}
}
}
@@ -0,0 +1,40 @@
<?php
/*
* This file is part of the Acme PHP project.
*
* (c) Titouan Galopin <galopintitouan@gmail.com>
*
* For the full copyright and license information, please view the LICENSE
* file that was distributed with this source code.
*/
namespace AcmePhp\Core\Challenge\Http;
use AcmePhp\Core\Challenge\SolverInterface;
use AcmePhp\Core\Challenge\ValidatorInterface;
use AcmePhp\Core\Protocol\AuthorizationChallenge;
/**
* Validator for pebble-challtestsrv.
*
* @author Titouan Galopin <galopintitouan@gmail.com>
*/
class MockHttpValidator implements ValidatorInterface
{
/**
* {@inheritdoc}
*/
public function supports(AuthorizationChallenge $authorizationChallenge, SolverInterface $solver): bool
{
return 'http-01' === $authorizationChallenge->getType() && $solver instanceof MockServerHttpSolver;
}
/**
* {@inheritdoc}
*/
public function isValid(AuthorizationChallenge $authorizationChallenge, SolverInterface $solver): bool
{
return true;
}
}
@@ -0,0 +1,58 @@
<?php
/*
* This file is part of the Acme PHP project.
*
* (c) Titouan Galopin <galopintitouan@gmail.com>
*
* For the full copyright and license information, please view the LICENSE
* file that was distributed with this source code.
*/
namespace AcmePhp\Core\Challenge\Http;
use AcmePhp\Core\Challenge\SolverInterface;
use AcmePhp\Core\Protocol\AuthorizationChallenge;
use GuzzleHttp\Client;
use GuzzleHttp\RequestOptions;
/**
* ACME HTTP solver talking to pebble-challtestsrv.
*
* @author Titouan Galopin <galopintitouan@gmail.com>
*/
class MockServerHttpSolver implements SolverInterface
{
/**
* {@inheritdoc}
*/
public function supports(AuthorizationChallenge $authorizationChallenge): bool
{
return 'http-01' === $authorizationChallenge->getType();
}
/**
* {@inheritdoc}
*/
public function solve(AuthorizationChallenge $authorizationChallenge)
{
(new Client())->post('http://localhost:8055/add-http01', [
RequestOptions::JSON => [
'token' => $authorizationChallenge->getToken(),
'content' => $authorizationChallenge->getPayload(),
],
]);
}
/**
* {@inheritdoc}
*/
public function cleanup(AuthorizationChallenge $authorizationChallenge)
{
(new Client())->post('http://localhost:8055/del-http01', [
RequestOptions::JSON => [
'token' => $authorizationChallenge->getToken(),
],
]);
}
}
@@ -0,0 +1,96 @@
<?php
/*
* This file is part of the Acme PHP project.
*
* (c) Titouan Galopin <galopintitouan@gmail.com>
*
* For the full copyright and license information, please view the LICENSE
* file that was distributed with this source code.
*/
namespace AcmePhp\Core\Challenge\Http;
use AcmePhp\Core\Challenge\SolverInterface;
use AcmePhp\Core\Protocol\AuthorizationChallenge;
use Symfony\Component\Console\Output\NullOutput;
use Symfony\Component\Console\Output\OutputInterface;
/**
* ACME HTTP solver with manual intervention.
*
* @author Jérémy Derussé <jeremy@derusse.com>
*/
class SimpleHttpSolver implements SolverInterface
{
/**
* @var HttpDataExtractor
*/
private $extractor;
/**
* @var OutputInterface
*/
private $output;
public function __construct(HttpDataExtractor $extractor = null, OutputInterface $output = null)
{
$this->extractor = $extractor ?: new HttpDataExtractor();
$this->output = $output ?: new NullOutput();
}
/**
* {@inheritdoc}
*/
public function supports(AuthorizationChallenge $authorizationChallenge): bool
{
return 'http-01' === $authorizationChallenge->getType();
}
/**
* {@inheritdoc}
*/
public function solve(AuthorizationChallenge $authorizationChallenge)
{
$checkUrl = $this->extractor->getCheckUrl($authorizationChallenge);
$checkContent = $this->extractor->getCheckContent($authorizationChallenge);
$this->output->writeln(
sprintf(
<<<'EOF'
Create a text file accessible on URL %s
containing the following content:
%s
Check in your browser that the URL %s returns
the authorization token above.
EOF
,
$checkUrl,
$checkContent,
$checkContent
)
);
}
/**
* {@inheritdoc}
*/
public function cleanup(AuthorizationChallenge $authorizationChallenge)
{
$checkUrl = $this->extractor->getCheckUrl($authorizationChallenge);
$this->output->writeln(
sprintf(
<<<'EOF'
You can now safely remove the challenge's file at %s
EOF
,
$checkUrl
)
);
}
}
@@ -0,0 +1,36 @@
<?php
/*
* This file is part of the Acme PHP project.
*
* (c) Titouan Galopin <galopintitouan@gmail.com>
*
* For the full copyright and license information, please view the LICENSE
* file that was distributed with this source code.
*/
namespace AcmePhp\Core\Challenge;
use AcmePhp\Core\Protocol\AuthorizationChallenge;
/**
* ACME challenge solver able to solve several challenges at once.
*
* @author Jérémy Derussé <jeremy@derusse.com>
*/
interface MultipleChallengesSolverInterface extends SolverInterface
{
/**
* Solve the given list of authorization challenge.
*
* @param AuthorizationChallenge[] $authorizationChallenges
*/
public function solveAll(array $authorizationChallenges);
/**
* Cleanup the environments after all challenges.
*
* @param AuthorizationChallenge[] $authorizationChallenges
*/
public function cleanupAll(array $authorizationChallenges);
}
+39
View File
@@ -0,0 +1,39 @@
<?php
/*
* This file is part of the Acme PHP project.
*
* (c) Titouan Galopin <galopintitouan@gmail.com>
*
* For the full copyright and license information, please view the LICENSE
* file that was distributed with this source code.
*/
namespace AcmePhp\Core\Challenge;
use AcmePhp\Core\Protocol\AuthorizationChallenge;
/**
* ACME challenge solver.
*
* @author Jérémy Derussé <jeremy@derusse.com>
*/
interface SolverInterface
{
/**
* Determines whether or not the solver supports a given Challenge.
*
* @return bool The solver supports the given challenge's type
*/
public function supports(AuthorizationChallenge $authorizationChallenge): bool;
/**
* Solve the given authorization challenge.
*/
public function solve(AuthorizationChallenge $authorizationChallenge);
/**
* Cleanup the environments after a successful challenge.
*/
public function cleanup(AuthorizationChallenge $authorizationChallenge);
}
@@ -0,0 +1,36 @@
<?php
/*
* This file is part of the Acme PHP project.
*
* (c) Titouan Galopin <galopintitouan@gmail.com>
*
* For the full copyright and license information, please view the LICENSE
* file that was distributed with this source code.
*/
namespace AcmePhp\Core\Challenge;
use AcmePhp\Core\Protocol\AuthorizationChallenge;
/**
* ACME challenge pre validator.
*
* @author Jérémy Derussé <jeremy@derusse.com>
*/
interface ValidatorInterface
{
/**
* Determines whether or not the validator supports a given Challenge.
*
* @return bool The validator supports the given challenge's type
*/
public function supports(AuthorizationChallenge $authorizationChallenge, SolverInterface $solver): bool;
/**
* Internally validate the challenge by performing the same kind of test than the CA.
*
* @return bool The challenge is valid
*/
public function isValid(AuthorizationChallenge $authorizationChallenge, SolverInterface $solver): bool;
}
@@ -0,0 +1,60 @@
<?php
/*
* This file is part of the Acme PHP project.
*
* (c) Titouan Galopin <galopintitouan@gmail.com>
*
* For the full copyright and license information, please view the LICENSE
* file that was distributed with this source code.
*/
namespace AcmePhp\Core\Challenge;
use AcmePhp\Core\Protocol\AuthorizationChallenge;
/**
* ACME Challenge validator who implement a retry strategy till the decorated validator successfully validate the
* challenge or the timeout is reached.
*
* @author Jérémy Derussé <jeremy@derusse.com>
*/
class WaitingValidator implements ValidatorInterface
{
/** @var ValidatorInterface */
private $validator;
/** @var int */
private $timeout;
public function __construct(ValidatorInterface $validator, int $timeout = 180)
{
$this->validator = $validator;
$this->timeout = $timeout;
}
/**
* {@inheritdoc}
*/
public function supports(AuthorizationChallenge $authorizationChallenge, SolverInterface $solver): bool
{
return $this->validator->supports($authorizationChallenge, $solver);
}
/**
* {@inheritdoc}
*/
public function isValid(AuthorizationChallenge $authorizationChallenge, SolverInterface $solver): bool
{
$limitEndTime = time() + $this->timeout;
do {
if ($this->validator->isValid($authorizationChallenge, $solver)) {
return true;
}
sleep(3);
} while ($limitEndTime > time());
return false;
}
}
@@ -0,0 +1,25 @@
<?php
/*
* This file is part of the Acme PHP project.
*
* (c) Titouan Galopin <galopintitouan@gmail.com>
*
* For the full copyright and license information, please view the LICENSE
* file that was distributed with this source code.
*/
namespace AcmePhp\Core\Exception;
/**
* Error reported by the client.
*
* @author Titouan Galopin <galopintitouan@gmail.com>
*/
class AcmeCoreClientException extends AcmeCoreException
{
public function __construct($message, \Exception $previous = null)
{
parent::__construct($message, 0, $previous);
}
}
@@ -0,0 +1,19 @@
<?php
/*
* This file is part of the Acme PHP project.
*
* (c) Titouan Galopin <galopintitouan@gmail.com>
*
* For the full copyright and license information, please view the LICENSE
* file that was distributed with this source code.
*/
namespace AcmePhp\Core\Exception;
/**
* @author Titouan Galopin <galopintitouan@gmail.com>
*/
class AcmeCoreException extends \RuntimeException
{
}
@@ -0,0 +1,27 @@
<?php
/*
* This file is part of the Acme PHP project.
*
* (c) Titouan Galopin <galopintitouan@gmail.com>
*
* For the full copyright and license information, please view the LICENSE
* file that was distributed with this source code.
*/
namespace AcmePhp\Core\Exception;
use Psr\Http\Message\RequestInterface;
/**
* Error reported by the server.
*
* @author Titouan Galopin <galopintitouan@gmail.com>
*/
class AcmeCoreServerException extends AcmeCoreException
{
public function __construct(RequestInterface $request, $message, \Exception $previous = null)
{
parent::__construct($message, $previous ? $previous->getCode() : 0, $previous);
}
}
@@ -0,0 +1,23 @@
<?php
/*
* This file is part of the Acme PHP project.
*
* (c) Titouan Galopin <galopintitouan@gmail.com>
*
* For the full copyright and license information, please view the LICENSE
* file that was distributed with this source code.
*/
namespace AcmePhp\Core\Exception;
/**
* @author Jérémy Derussé <jeremy@derusse.com>
*/
class AcmeDnsResolutionException extends AcmeCoreException
{
public function __construct($message, \Exception $previous = null)
{
parent::__construct(null === $message ? 'An exception was thrown during resolution of DNS' : $message, 0, $previous);
}
}
@@ -0,0 +1,23 @@
<?php
/*
* This file is part of the Acme PHP project.
*
* (c) Titouan Galopin <galopintitouan@gmail.com>
*
* For the full copyright and license information, please view the LICENSE
* file that was distributed with this source code.
*/
namespace AcmePhp\Core\Exception\Protocol;
/**
* @author Titouan Galopin <galopintitouan@gmail.com>
*/
class CertificateRequestFailedException extends ProtocolException
{
public function __construct(string $response)
{
parent::__construct(sprintf('Certificate request failed (response: %s)', $response));
}
}
@@ -0,0 +1,23 @@
<?php
/*
* This file is part of the Acme PHP project.
*
* (c) Titouan Galopin <galopintitouan@gmail.com>
*
* For the full copyright and license information, please view the LICENSE
* file that was distributed with this source code.
*/
namespace AcmePhp\Core\Exception\Protocol;
/**
* @author Titouan Galopin <galopintitouan@gmail.com>
*/
class CertificateRequestTimedOutException extends ProtocolException
{
public function __construct(string $response)
{
parent::__construct(sprintf('Certificate request timed out (response: %s)', $response));
}
}
@@ -0,0 +1,18 @@
<?php
/*
* This file is part of the Acme PHP project.
*
* (c) Titouan Galopin <galopintitouan@gmail.com>
*
* For the full copyright and license information, please view the LICENSE
* file that was distributed with this source code.
*/
namespace AcmePhp\Core\Exception\Protocol;
use AcmePhp\Core\Exception\AcmeCoreClientException;
class CertificateRevocationException extends AcmeCoreClientException
{
}
@@ -0,0 +1,38 @@
<?php
/*
* This file is part of the Acme PHP project.
*
* (c) Titouan Galopin <galopintitouan@gmail.com>
*
* For the full copyright and license information, please view the LICENSE
* file that was distributed with this source code.
*/
namespace AcmePhp\Core\Exception\Protocol;
/**
* @author Titouan Galopin <galopintitouan@gmail.com>
*/
class ChallengeFailedException extends ProtocolException
{
private $response;
public function __construct($response, \Exception $previous = null)
{
parent::__construct(
sprintf('Challenge failed (response: %s).', json_encode($response)),
$previous
);
$this->response = $response;
}
/**
* @return array
*/
public function getResponse()
{
return $this->response;
}
}
@@ -0,0 +1,23 @@
<?php
/*
* This file is part of the Acme PHP project.
*
* (c) Titouan Galopin <galopintitouan@gmail.com>
*
* For the full copyright and license information, please view the LICENSE
* file that was distributed with this source code.
*/
namespace AcmePhp\Core\Exception\Protocol;
/**
* @author Titouan Galopin <galopintitouan@gmail.com>
*/
class ChallengeNotSupportedException extends ProtocolException
{
public function __construct(\Exception $previous = null)
{
parent::__construct('This ACME server does not expose supported challenge.', $previous);
}
}
@@ -0,0 +1,38 @@
<?php
/*
* This file is part of the Acme PHP project.
*
* (c) Titouan Galopin <galopintitouan@gmail.com>
*
* For the full copyright and license information, please view the LICENSE
* file that was distributed with this source code.
*/
namespace AcmePhp\Core\Exception\Protocol;
/**
* @author Titouan Galopin <galopintitouan@gmail.com>
*/
class ChallengeTimedOutException extends ProtocolException
{
private $response;
public function __construct($response, \Exception $previous = null)
{
parent::__construct(
sprintf('Challenge timed out (response: %s).', json_encode($response)),
$previous
);
$this->response = $response;
}
/**
* @return array
*/
public function getResponse()
{
return $this->response;
}
}
@@ -0,0 +1,19 @@
<?php
/*
* This file is part of the Acme PHP project.
*
* (c) Titouan Galopin <galopintitouan@gmail.com>
*
* For the full copyright and license information, please view the LICENSE
* file that was distributed with this source code.
*/
namespace AcmePhp\Core\Exception\Protocol;
/**
* @author Titouan Galopin <galopintitouan@gmail.com>
*/
class ExpectedJsonException extends ProtocolException
{
}
@@ -0,0 +1,23 @@
<?php
/*
* This file is part of the Acme PHP project.
*
* (c) Titouan Galopin <galopintitouan@gmail.com>
*
* For the full copyright and license information, please view the LICENSE
* file that was distributed with this source code.
*/
namespace AcmePhp\Core\Exception\Protocol;
use AcmePhp\Core\Exception\AcmeCoreClientException;
/**
* Error because the protocol was not respected.
*
* @author Titouan Galopin <galopintitouan@gmail.com>
*/
class ProtocolException extends AcmeCoreClientException
{
}
@@ -0,0 +1,30 @@
<?php
/*
* This file is part of the Acme PHP project.
*
* (c) Titouan Galopin <galopintitouan@gmail.com>
*
* For the full copyright and license information, please view the LICENSE
* file that was distributed with this source code.
*/
namespace AcmePhp\Core\Exception\Server;
use AcmePhp\Core\Exception\AcmeCoreServerException;
use Psr\Http\Message\RequestInterface;
/**
* @author Titouan Galopin <galopintitouan@gmail.com>
*/
class BadCsrServerException extends AcmeCoreServerException
{
public function __construct(RequestInterface $request, string $detail, \Exception $previous = null)
{
parent::__construct(
$request,
'[badCSR] The CSR is unacceptable (e.g., due to a short key): '.$detail,
$previous
);
}
}
@@ -0,0 +1,30 @@
<?php
/*
* This file is part of the Acme PHP project.
*
* (c) Titouan Galopin <galopintitouan@gmail.com>
*
* For the full copyright and license information, please view the LICENSE
* file that was distributed with this source code.
*/
namespace AcmePhp\Core\Exception\Server;
use AcmePhp\Core\Exception\AcmeCoreServerException;
use Psr\Http\Message\RequestInterface;
/**
* @author Titouan Galopin <galopintitouan@gmail.com>
*/
class BadNonceServerException extends AcmeCoreServerException
{
public function __construct(RequestInterface $request, string $detail, \Exception $previous = null)
{
parent::__construct(
$request,
'[badNonce] The client sent an unacceptable anti-replay nonce: '.$detail,
$previous
);
}
}
@@ -0,0 +1,30 @@
<?php
/*
* This file is part of the Acme PHP project.
*
* (c) Titouan Galopin <galopintitouan@gmail.com>
*
* For the full copyright and license information, please view the LICENSE
* file that was distributed with this source code.
*/
namespace AcmePhp\Core\Exception\Server;
use AcmePhp\Core\Exception\AcmeCoreServerException;
use Psr\Http\Message\RequestInterface;
/**
* @author Alex Plekhanov <alex@plekhanov.dev>
*/
class CaaServerException extends AcmeCoreServerException
{
public function __construct(RequestInterface $request, string $detail, \Exception $previous = null)
{
parent::__construct(
$request,
'[caa] Certification Authority Authorization (CAA) records forbid the CA from issuing a certificate: '.$detail,
$previous
);
}
}
@@ -0,0 +1,30 @@
<?php
/*
* This file is part of the Acme PHP project.
*
* (c) Titouan Galopin <galopintitouan@gmail.com>
*
* For the full copyright and license information, please view the LICENSE
* file that was distributed with this source code.
*/
namespace AcmePhp\Core\Exception\Server;
use AcmePhp\Core\Exception\AcmeCoreServerException;
use Psr\Http\Message\RequestInterface;
/**
* @author Titouan Galopin <galopintitouan@gmail.com>
*/
class ConnectionServerException extends AcmeCoreServerException
{
public function __construct(RequestInterface $request, string $detail, \Exception $previous = null)
{
parent::__construct(
$request,
'[connection] The server could not connect to the client for DV: '.$detail,
$previous
);
}
}
@@ -0,0 +1,30 @@
<?php
/*
* This file is part of the Acme PHP project.
*
* (c) Titouan Galopin <galopintitouan@gmail.com>
*
* For the full copyright and license information, please view the LICENSE
* file that was distributed with this source code.
*/
namespace AcmePhp\Core\Exception\Server;
use AcmePhp\Core\Exception\AcmeCoreServerException;
use Psr\Http\Message\RequestInterface;
/**
* @author Alex Plekhanov <alex@plekhanov.dev>
*/
class DnsServerException extends AcmeCoreServerException
{
public function __construct(RequestInterface $request, string $detail, \Exception $previous = null)
{
parent::__construct(
$request,
'[dns] There was a problem with a DNS query during identifier validation: '.$detail,
$previous
);
}
}
@@ -0,0 +1,30 @@
<?php
/*
* This file is part of the Acme PHP project.
*
* (c) Titouan Galopin <galopintitouan@gmail.com>
*
* For the full copyright and license information, please view the LICENSE
* file that was distributed with this source code.
*/
namespace AcmePhp\Core\Exception\Server;
use AcmePhp\Core\Exception\AcmeCoreServerException;
use Psr\Http\Message\RequestInterface;
/**
* @author Alex Plekhanov <alex@plekhanov.dev>
*/
class IncorrectResponseServerException extends AcmeCoreServerException
{
public function __construct(RequestInterface $request, string $detail, \Exception $previous = null)
{
parent::__construct(
$request,
"[incorrectResponse] Response received didnt match the challenge's requirements: ".$detail,
$previous
);
}
}
@@ -0,0 +1,30 @@
<?php
/*
* This file is part of the Acme PHP project.
*
* (c) Titouan Galopin <galopintitouan@gmail.com>
*
* For the full copyright and license information, please view the LICENSE
* file that was distributed with this source code.
*/
namespace AcmePhp\Core\Exception\Server;
use AcmePhp\Core\Exception\AcmeCoreServerException;
use Psr\Http\Message\RequestInterface;
/**
* @author Titouan Galopin <galopintitouan@gmail.com>
*/
class InternalServerException extends AcmeCoreServerException
{
public function __construct(RequestInterface $request, string $detail, \Exception $previous = null)
{
parent::__construct(
$request,
'[serverInternal] The server experienced an internal error: '.$detail,
$previous
);
}
}
@@ -0,0 +1,30 @@
<?php
/*
* This file is part of the Acme PHP project.
*
* (c) Titouan Galopin <galopintitouan@gmail.com>
*
* For the full copyright and license information, please view the LICENSE
* file that was distributed with this source code.
*/
namespace AcmePhp\Core\Exception\Server;
use AcmePhp\Core\Exception\AcmeCoreServerException;
use Psr\Http\Message\RequestInterface;
/**
* @author Alex Plekhanov <alex@plekhanov.dev>
*/
class InvalidContactServerException extends AcmeCoreServerException
{
public function __construct(RequestInterface $request, string $detail, \Exception $previous = null)
{
parent::__construct(
$request,
'[invalidContact] A contact URL for an account was invalid: '.$detail,
$previous
);
}
}
@@ -0,0 +1,30 @@
<?php
/*
* This file is part of the Acme PHP project.
*
* (c) Titouan Galopin <galopintitouan@gmail.com>
*
* For the full copyright and license information, please view the LICENSE
* file that was distributed with this source code.
*/
namespace AcmePhp\Core\Exception\Server;
use AcmePhp\Core\Exception\AcmeCoreServerException;
use Psr\Http\Message\RequestInterface;
/**
* @author Titouan Galopin <galopintitouan@gmail.com>
*/
class InvalidEmailServerException extends AcmeCoreServerException
{
public function __construct(RequestInterface $request, string $detail, \Exception $previous = null)
{
parent::__construct(
$request,
'[invalidEmail] This email is unacceptable (e.g., it is invalid): '.$detail,
$previous
);
}
}
@@ -0,0 +1,30 @@
<?php
/*
* This file is part of the Acme PHP project.
*
* (c) Titouan Galopin <galopintitouan@gmail.com>
*
* For the full copyright and license information, please view the LICENSE
* file that was distributed with this source code.
*/
namespace AcmePhp\Core\Exception\Server;
use AcmePhp\Core\Exception\AcmeCoreServerException;
use Psr\Http\Message\RequestInterface;
/**
* @author Titouan Galopin <galopintitouan@gmail.com>
*/
class MalformedServerException extends AcmeCoreServerException
{
public function __construct(RequestInterface $request, string $detail, \Exception $previous = null)
{
parent::__construct(
$request,
'[malformed] The request message was malformed: '.$detail,
$previous
);
}
}
@@ -0,0 +1,27 @@
<?php
/*
* This file is part of the Acme PHP project.
*
* (c) Titouan Galopin <galopintitouan@gmail.com>
*
* For the full copyright and license information, please view the LICENSE
* file that was distributed with this source code.
*/
namespace AcmePhp\Core\Exception\Server;
use AcmePhp\Core\Exception\AcmeCoreServerException;
use Psr\Http\Message\RequestInterface;
class OrderNotReadyServerException extends AcmeCoreServerException
{
public function __construct(RequestInterface $request, string $detail, \Exception $previous = null)
{
parent::__construct(
$request,
'[orderNotReady] Order could not be finalized: '.$detail,
$previous
);
}
}
@@ -0,0 +1,30 @@
<?php
/*
* This file is part of the Acme PHP project.
*
* (c) Titouan Galopin <galopintitouan@gmail.com>
*
* For the full copyright and license information, please view the LICENSE
* file that was distributed with this source code.
*/
namespace AcmePhp\Core\Exception\Server;
use AcmePhp\Core\Exception\AcmeCoreServerException;
use Psr\Http\Message\RequestInterface;
/**
* @author Titouan Galopin <galopintitouan@gmail.com>
*/
class RateLimitedServerException extends AcmeCoreServerException
{
public function __construct(RequestInterface $request, string $detail, \Exception $previous = null)
{
parent::__construct(
$request,
'[rateLimited] This client reached the rate limit of the server: '.$detail,
$previous
);
}
}
@@ -0,0 +1,30 @@
<?php
/*
* This file is part of the Acme PHP project.
*
* (c) Titouan Galopin <galopintitouan@gmail.com>
*
* For the full copyright and license information, please view the LICENSE
* file that was distributed with this source code.
*/
namespace AcmePhp\Core\Exception\Server;
use AcmePhp\Core\Exception\AcmeCoreServerException;
use Psr\Http\Message\RequestInterface;
/**
* @author Alex Plekhanov <alex@plekhanov.dev>
*/
class RejectedIdentifierServerException extends AcmeCoreServerException
{
public function __construct(RequestInterface $request, string $detail, \Exception $previous = null)
{
parent::__construct(
$request,
'[rejectedIdentifier] The server will not issue certificates for the identifier: '.$detail,
$previous
);
}
}
@@ -0,0 +1,30 @@
<?php
/*
* This file is part of the Acme PHP project.
*
* (c) Titouan Galopin <galopintitouan@gmail.com>
*
* For the full copyright and license information, please view the LICENSE
* file that was distributed with this source code.
*/
namespace AcmePhp\Core\Exception\Server;
use AcmePhp\Core\Exception\AcmeCoreServerException;
use Psr\Http\Message\RequestInterface;
/**
* @author Titouan Galopin <galopintitouan@gmail.com>
*/
class TlsServerException extends AcmeCoreServerException
{
public function __construct(RequestInterface $request, string $detail, \Exception $previous = null)
{
parent::__construct(
$request,
'[tls] The server experienced a TLS error during DV: '.$detail,
$previous
);
}
}
@@ -0,0 +1,30 @@
<?php
/*
* This file is part of the Acme PHP project.
*
* (c) Titouan Galopin <galopintitouan@gmail.com>
*
* For the full copyright and license information, please view the LICENSE
* file that was distributed with this source code.
*/
namespace AcmePhp\Core\Exception\Server;
use AcmePhp\Core\Exception\AcmeCoreServerException;
use Psr\Http\Message\RequestInterface;
/**
* @author Titouan Galopin <galopintitouan@gmail.com>
*/
class UnauthorizedServerException extends AcmeCoreServerException
{
public function __construct(RequestInterface $request, string $detail, \Exception $previous = null)
{
parent::__construct(
$request,
'[unauthorized] The client lacks sufficient authorization: '.$detail,
$previous
);
}
}
@@ -0,0 +1,30 @@
<?php
/*
* This file is part of the Acme PHP project.
*
* (c) Titouan Galopin <galopintitouan@gmail.com>
*
* For the full copyright and license information, please view the LICENSE
* file that was distributed with this source code.
*/
namespace AcmePhp\Core\Exception\Server;
use AcmePhp\Core\Exception\AcmeCoreServerException;
use Psr\Http\Message\RequestInterface;
/**
* @author Titouan Galopin <galopintitouan@gmail.com>
*/
class UnknownHostServerException extends AcmeCoreServerException
{
public function __construct(RequestInterface $request, string $detail, \Exception $previous = null)
{
parent::__construct(
$request,
'[unknownHost] The server could not resolve a domain name: '.$detail,
$previous
);
}
}
@@ -0,0 +1,30 @@
<?php
/*
* This file is part of the Acme PHP project.
*
* (c) Titouan Galopin <galopintitouan@gmail.com>
*
* For the full copyright and license information, please view the LICENSE
* file that was distributed with this source code.
*/
namespace AcmePhp\Core\Exception\Server;
use AcmePhp\Core\Exception\AcmeCoreServerException;
use Psr\Http\Message\RequestInterface;
/**
* @author Alex Plekhanov <alex@plekhanov.dev>
*/
class UnsupportedContactServerException extends AcmeCoreServerException
{
public function __construct(RequestInterface $request, string $detail, \Exception $previous = null)
{
parent::__construct(
$request,
'[unsupportedContact] A contact URL for an account used an unsupported protocol scheme: '.$detail,
$previous
);
}
}
@@ -0,0 +1,30 @@
<?php
/*
* This file is part of the Acme PHP project.
*
* (c) Titouan Galopin <galopintitouan@gmail.com>
*
* For the full copyright and license information, please view the LICENSE
* file that was distributed with this source code.
*/
namespace AcmePhp\Core\Exception\Server;
use AcmePhp\Core\Exception\AcmeCoreServerException;
use Psr\Http\Message\RequestInterface;
/**
* @author Alex Plekhanov <alex@plekhanov.dev>
*/
class UnsupportedIdentifierServerException extends AcmeCoreServerException
{
public function __construct(RequestInterface $request, string $detail, \Exception $previous = null)
{
parent::__construct(
$request,
'[unsupportedIdentifier] An identifier is of an unsupported type: '.$detail,
$previous
);
}
}
@@ -0,0 +1,30 @@
<?php
/*
* This file is part of the Acme PHP project.
*
* (c) Titouan Galopin <galopintitouan@gmail.com>
*
* For the full copyright and license information, please view the LICENSE
* file that was distributed with this source code.
*/
namespace AcmePhp\Core\Exception\Server;
use AcmePhp\Core\Exception\AcmeCoreServerException;
use Psr\Http\Message\RequestInterface;
/**
* @author Alex Plekhanov <alex@plekhanov.dev>
*/
class UserActionRequiredServerException extends AcmeCoreServerException
{
public function __construct(RequestInterface $request, string $detail, \Exception $previous = null)
{
parent::__construct(
$request,
'[userActionRequired] Visit the “instance” URL and take actions specified there: '.$detail,
$previous
);
}
}
@@ -0,0 +1,67 @@
<?php
/*
* This file is part of the Acme PHP project.
*
* (c) Titouan Galopin <galopintitouan@gmail.com>
*
* For the full copyright and license information, please view the LICENSE
* file that was distributed with this source code.
*/
namespace AcmePhp\Core\Filesystem\Adapter;
use AcmePhp\Core\Filesystem\FilesystemInterface;
use League\Flysystem\FilesystemInterface as FlysystemFilesystemInterface;
class FlysystemAdapter implements FilesystemInterface
{
/**
* @var FlysystemFilesystemInterface
*/
private $filesystem;
public function __construct(FlysystemFilesystemInterface $filesystem)
{
$this->filesystem = $filesystem;
}
public function write(string $path, string $content)
{
$isOnRemote = $this->filesystem->has($path);
if ($isOnRemote && !$this->filesystem->update($path, $content)) {
throw $this->createRuntimeException($path, 'updated');
}
if (!$isOnRemote && !$this->filesystem->write($path, $content)) {
throw $this->createRuntimeException($path, 'created');
}
}
public function delete(string $path)
{
$isOnRemote = $this->filesystem->has($path);
if ($isOnRemote && !$this->filesystem->delete($path)) {
throw $this->createRuntimeException($path, 'delete');
}
}
public function createDir(string $path)
{
$isOnRemote = $this->filesystem->has($path);
if (!$isOnRemote && !$this->filesystem->createDir($path)) {
throw $this->createRuntimeException($path, 'created');
}
}
private function createRuntimeException(string $path, string $action): \RuntimeException
{
return new \RuntimeException(
sprintf(
'File %s could not be %s because: %s',
$path,
$action,
error_get_last()
)
);
}
}
@@ -0,0 +1,28 @@
<?php
/*
* This file is part of the Acme PHP project.
*
* (c) Titouan Galopin <galopintitouan@gmail.com>
*
* For the full copyright and license information, please view the LICENSE
* file that was distributed with this source code.
*/
namespace AcmePhp\Core\Filesystem\Adapter;
use AcmePhp\Core\Filesystem\FilesystemFactoryInterface;
use AcmePhp\Core\Filesystem\FilesystemInterface;
use League\Flysystem\Adapter\Ftp;
use League\Flysystem\Filesystem;
class FlysystemFtpFactory implements FilesystemFactoryInterface
{
/**
* {@inheritdoc}
*/
public function create(array $config): FilesystemInterface
{
return new FlysystemAdapter(new Filesystem(new Ftp($config)));
}
}
@@ -0,0 +1,31 @@
<?php
/*
* This file is part of the Acme PHP project.
*
* (c) Titouan Galopin <galopintitouan@gmail.com>
*
* For the full copyright and license information, please view the LICENSE
* file that was distributed with this source code.
*/
namespace AcmePhp\Core\Filesystem\Adapter;
use AcmePhp\Core\Filesystem\FilesystemFactoryInterface;
use AcmePhp\Core\Filesystem\FilesystemInterface;
use League\Flysystem\Adapter\Local;
use League\Flysystem\Filesystem;
use Webmozart\Assert\Assert;
class FlysystemLocalFactory implements FilesystemFactoryInterface
{
/**
* {@inheritdoc}
*/
public function create(array $config): FilesystemInterface
{
Assert::keyExists($config, 'root', 'create::$config expected an array with the key %s.');
return new FlysystemAdapter(new Filesystem(new Local($config['root'])));
}
}
@@ -0,0 +1,28 @@
<?php
/*
* This file is part of the Acme PHP project.
*
* (c) Titouan Galopin <galopintitouan@gmail.com>
*
* For the full copyright and license information, please view the LICENSE
* file that was distributed with this source code.
*/
namespace AcmePhp\Core\Filesystem\Adapter;
use AcmePhp\Core\Filesystem\FilesystemFactoryInterface;
use AcmePhp\Core\Filesystem\FilesystemInterface;
use League\Flysystem\Filesystem;
use League\Flysystem\Sftp\SftpAdapter;
class FlysystemSftpFactory implements FilesystemFactoryInterface
{
/**
* {@inheritdoc}
*/
public function create(array $config): FilesystemInterface
{
return new FlysystemAdapter(new Filesystem(new SftpAdapter($config)));
}
}
@@ -0,0 +1,23 @@
<?php
/*
* This file is part of the Acme PHP project.
*
* (c) Titouan Galopin <galopintitouan@gmail.com>
*
* For the full copyright and license information, please view the LICENSE
* file that was distributed with this source code.
*/
namespace AcmePhp\Core\Filesystem\Adapter;
use League\Flysystem\Filesystem;
use League\Flysystem\Memory\MemoryAdapter;
class NullAdapter extends FlysystemAdapter
{
public function __construct()
{
parent::__construct(new Filesystem(new MemoryAdapter()));
}
}
@@ -0,0 +1,20 @@
<?php
/*
* This file is part of the Acme PHP project.
*
* (c) Titouan Galopin <galopintitouan@gmail.com>
*
* For the full copyright and license information, please view the LICENSE
* file that was distributed with this source code.
*/
namespace AcmePhp\Core\Filesystem;
interface FilesystemFactoryInterface
{
/**
* Create a new Filesystem.
*/
public function create(array $config): FilesystemInterface;
}
@@ -0,0 +1,30 @@
<?php
/*
* This file is part of the Acme PHP project.
*
* (c) Titouan Galopin <galopintitouan@gmail.com>
*
* For the full copyright and license information, please view the LICENSE
* file that was distributed with this source code.
*/
namespace AcmePhp\Core\Filesystem;
interface FilesystemInterface
{
/**
* Write content to a file.
*/
public function write(string $path, string $content);
/**
* Delete a file.
*/
public function delete(string $path);
/**
* Delete a directory.
*/
public function createDir(string $path);
}
+37
View File
@@ -0,0 +1,37 @@
<?php
/*
* This file is part of the Acme PHP project.
*
* (c) Titouan Galopin <galopintitouan@gmail.com>
*
* For the full copyright and license information, please view the LICENSE
* file that was distributed with this source code.
*/
namespace AcmePhp\Core\Http;
/**
* Encode and decode safely in base64.
*
* @author Titouan Galopin <galopintitouan@gmail.com>
*/
class Base64SafeEncoder
{
public function encode(string $input): string
{
return str_replace('=', '', strtr(base64_encode($input), '+/', '-_'));
}
public function decode(string $input): string
{
$remainder = \strlen($input) % 4;
if ($remainder) {
$padlen = 4 - $remainder;
$input .= str_repeat('=', $padlen);
}
return base64_decode(strtr($input, '-_', '+/'));
}
}
+421
View File
@@ -0,0 +1,421 @@
<?php
/*
* This file is part of the Acme PHP project.
*
* (c) Titouan Galopin <galopintitouan@gmail.com>
*
* For the full copyright and license information, please view the LICENSE
* file that was distributed with this source code.
*/
namespace AcmePhp\Core\Http;
use AcmePhp\Core\Exception\AcmeCoreClientException;
use AcmePhp\Core\Exception\AcmeCoreServerException;
use AcmePhp\Core\Exception\Protocol\ExpectedJsonException;
use AcmePhp\Core\Exception\Server\BadNonceServerException;
use AcmePhp\Core\Protocol\ExternalAccount;
use AcmePhp\Core\Util\JsonDecoder;
use AcmePhp\Ssl\KeyPair;
use AcmePhp\Ssl\Parser\KeyParser;
use AcmePhp\Ssl\Signer\DataSigner;
use GuzzleHttp\ClientInterface;
use GuzzleHttp\Exception\RequestException;
use GuzzleHttp\Psr7\Header;
use GuzzleHttp\Psr7\Request;
use GuzzleHttp\Psr7\Utils;
use Lcobucci\JWT\Signer\Hmac\Sha256;
use Lcobucci\JWT\Signer\Key\InMemory;
use Psr\Http\Message\ResponseInterface;
/**
* Guzzle HTTP client wrapper to send requests signed with the account KeyPair.
*
* @author Titouan Galopin <galopintitouan@gmail.com>
*/
class SecureHttpClient
{
/**
* @var KeyPair
*/
private $accountKeyPair;
/**
* @var ClientInterface
*/
private $httpClient;
/**
* @var Base64SafeEncoder
*/
private $base64Encoder;
/**
* @var KeyParser
*/
private $keyParser;
/**
* @var DataSigner
*/
private $dataSigner;
/**
* @var ServerErrorHandler
*/
private $errorHandler;
/**
* @var ResponseInterface
*/
private $lastResponse;
/**
* @var string
*/
private $nonceEndpoint;
public function __construct(
KeyPair $accountKeyPair,
ClientInterface $httpClient,
Base64SafeEncoder $base64Encoder,
KeyParser $keyParser,
DataSigner $dataSigner,
ServerErrorHandler $errorHandler
) {
$this->accountKeyPair = $accountKeyPair;
$this->httpClient = $httpClient;
$this->base64Encoder = $base64Encoder;
$this->keyParser = $keyParser;
$this->dataSigner = $dataSigner;
$this->errorHandler = $errorHandler;
}
public function getJWK(): array
{
$privateKey = $this->accountKeyPair->getPrivateKey();
$parsedKey = $this->keyParser->parse($privateKey);
switch ($parsedKey->getType()) {
case OPENSSL_KEYTYPE_RSA:
return [
// this order matters
'e' => $this->base64Encoder->encode($parsedKey->getDetail('e')),
'kty' => 'RSA',
'n' => $this->base64Encoder->encode($parsedKey->getDetail('n')),
];
case OPENSSL_KEYTYPE_EC:
return [
// this order matters
'crv' => 'P-'.$parsedKey->getBits(),
'kty' => 'EC',
'x' => $this->base64Encoder->encode($parsedKey->getDetail('x')),
'y' => $this->base64Encoder->encode($parsedKey->getDetail('y')),
];
default:
throw new AcmeCoreClientException('Private key type not supported');
}
}
public function getJWKThumbprint(): string
{
return hash('sha256', json_encode($this->getJWK()), true);
}
/**
* Generates a payload signed with account's KID.
*
* @param string|array|null $payload
*/
public function signKidPayload(string $endpoint, string $account, $payload = null, bool $withNonce = true): array
{
$protected = ['alg' => $this->getAlg(), 'kid' => $account, 'url' => $endpoint];
if ($withNonce) {
$protected['nonce'] = $this->getNonce();
}
return $this->signPayload($protected, $payload);
}
/**
* Generates a payload signed with JWK.
*
* @param string|array|null $payload
*/
public function signJwkPayload(string $endpoint, $payload = null, bool $withNonce = true): array
{
$protected = ['alg' => $this->getAlg(), 'jwk' => $this->getJWK(), 'url' => $endpoint];
if ($withNonce) {
$protected['nonce'] = $this->getNonce();
}
return $this->signPayload($protected, $payload);
}
/**
* Generates an External Account Binding payload signed with JWS.
*
* @param string|array|null $payload
*/
public function createExternalAccountPayload(ExternalAccount $externalAccount, string $url): array
{
$signer = new Sha256();
$protected = [
'alg' => method_exists($signer, 'algorithmId') ? $signer->algorithmId() : $signer->getAlgorithmId(),
'kid' => $externalAccount->getId(),
'url' => $url,
];
$encodedProtected = $this->base64Encoder->encode(json_encode($protected, JSON_UNESCAPED_SLASHES));
$encodedPayload = $this->base64Encoder->encode(json_encode($this->getJWK(), JSON_UNESCAPED_SLASHES));
$hmacKey = $this->base64Encoder->decode($externalAccount->getHmacKey());
$hmacKey = class_exists(InMemory::class) ? InMemory::plainText($hmacKey) : $hmacKey;
$signature = $this->base64Encoder->encode($signer->sign($encodedProtected.'.'.$encodedPayload, $hmacKey));
return [
'protected' => $encodedProtected,
'payload' => $encodedPayload,
'signature' => $signature,
];
}
/**
* Send a request encoded in the format defined by the ACME protocol
* and its content (optionally parsed as JSON).
*
* @throws AcmeCoreClientException when an error occured during response parsing
* @throws ExpectedJsonException when $returnJson = true and the response is not valid JSON
* @throws AcmeCoreServerException when the ACME server returns an error HTTP status code
*
* @return array|string Array of parsed JSON if $returnJson = true, string otherwise
*/
public function request(string $method, string $endpoint, array $data = [], bool $returnJson = true)
{
$response = $this->rawRequest($method, $endpoint, $data);
$body = Utils::copyToString($response->getBody());
if (!$returnJson) {
return $body;
}
try {
if ('' === $body) {
throw new \InvalidArgumentException('Empty body received.');
}
$data = JsonDecoder::decode($body, true);
} catch (\InvalidArgumentException $exception) {
throw new ExpectedJsonException(sprintf('ACME client expected valid JSON as a response to request "%s %s" (given: "%s")', $method, $endpoint, ServerErrorHandler::getResponseBodySummary($response)), $exception);
}
return $data;
}
/**
* Send a request encoded in the format defined by the ACME protocol and return the response object.
*
* @throws AcmeCoreClientException when an error occured during response parsing
* @throws ExpectedJsonException when $returnJson = true and the response is not valid JSON
* @throws AcmeCoreServerException when the ACME server returns an error HTTP status code
*/
public function rawRequest(string $method, string $endpoint, array $data = []): ResponseInterface
{
$call = function () use ($method, $endpoint, $data) {
$request = $this->createRequest($method, $endpoint, $data);
try {
$this->lastResponse = $this->httpClient->send($request);
} catch (\Exception $exception) {
$this->handleClientException($request, $exception);
}
return $request;
};
try {
$call();
} catch (BadNonceServerException $e) {
$call();
}
return $this->lastResponse;
}
public function setAccountKeyPair(KeyPair $keyPair)
{
$this->accountKeyPair = $keyPair;
}
public function getLastCode(): int
{
return $this->lastResponse->getStatusCode();
}
public function getLastLocation(): string
{
return $this->lastResponse->getHeaderLine('Location');
}
public function getLastLinks(): array
{
return Header::parse($this->lastResponse->getHeader('Link'));
}
public function getAccountKeyPair(): KeyPair
{
return $this->accountKeyPair;
}
public function getKeyParser(): KeyParser
{
return $this->keyParser;
}
public function getDataSigner(): DataSigner
{
return $this->dataSigner;
}
public function setNonceEndpoint(string $endpoint)
{
$this->nonceEndpoint = $endpoint;
}
public function getBase64Encoder(): Base64SafeEncoder
{
return $this->base64Encoder;
}
/**
* Sign the given Payload.
*/
private function signPayload(array $protected, array $payload = null): array
{
if (!isset($protected['alg'])) {
throw new \InvalidArgumentException('The property "alg" is required in the protected array');
}
$alg = $protected['alg'];
$privateKey = $this->accountKeyPair->getPrivateKey();
list($algorithm, $format) = $this->extractSignOptionFromJWSAlg($alg);
$encodedProtected = $this->base64Encoder->encode(json_encode($protected, JSON_UNESCAPED_SLASHES));
if (null === $payload) {
$encodedPayload = '';
} elseif ([] === $payload) {
$encodedPayload = $this->base64Encoder->encode('{}');
} else {
$encodedPayload = $this->base64Encoder->encode(json_encode($payload, JSON_UNESCAPED_SLASHES));
}
$signature = $this->base64Encoder->encode(
$this->dataSigner->signData($encodedProtected.'.'.$encodedPayload, $privateKey, $algorithm, $format)
);
return [
'protected' => $encodedProtected,
'payload' => $encodedPayload,
'signature' => $signature,
];
}
private function createRequest($method, $endpoint, $data)
{
$request = new Request($method, $endpoint);
$request = $request->withHeader('Accept', 'application/json,application/jose+json,');
if ('POST' === $method && \is_array($data)) {
$request = $request->withHeader('Content-Type', 'application/jose+json');
$request = $request->withBody(Utils::streamFor(json_encode($data)));
}
return $request;
}
private function handleClientException(Request $request, \Exception $exception)
{
if ($exception instanceof RequestException && $exception->getResponse() instanceof ResponseInterface) {
$this->lastResponse = $exception->getResponse();
throw $this->errorHandler->createAcmeExceptionForResponse($request, $this->lastResponse, $exception);
}
throw new AcmeCoreClientException(sprintf('An error occured during request "%s %s"', $request->getMethod(), $request->getUri()), $exception);
}
private function getNonce(): ?string
{
if ($this->lastResponse && $this->lastResponse->hasHeader('Replay-Nonce')) {
return $this->lastResponse->getHeaderLine('Replay-Nonce');
}
if (null !== $this->nonceEndpoint) {
$this->request('HEAD', $this->nonceEndpoint, [], false);
if ($this->lastResponse->hasHeader('Replay-Nonce')) {
return $this->lastResponse->getHeaderLine('Replay-Nonce');
}
}
return null;
}
private function getAlg(): string
{
$privateKey = $this->accountKeyPair->getPrivateKey();
$parsedKey = $this->keyParser->parse($privateKey);
switch ($parsedKey->getType()) {
case OPENSSL_KEYTYPE_RSA:
return 'RS256';
case OPENSSL_KEYTYPE_EC:
switch ($parsedKey->getBits()) {
case 256:
case 384:
return 'ES'.$parsedKey->getBits();
case 521:
return 'ES512';
}
// no break to let the default case
default:
throw new AcmeCoreClientException('Private key type is not supported');
}
}
private function extractSignOptionFromJWSAlg($alg): array
{
if (!preg_match('/^([A-Z]+)(\d+)$/', $alg, $match)) {
throw new AcmeCoreClientException(sprintf('The given "%s" algorithm is not supported', $alg));
}
if (!\defined('OPENSSL_ALGO_SHA'.$match[2])) {
throw new AcmeCoreClientException(sprintf('The given "%s" algorithm is not supported', $alg));
}
$algorithm = \constant('OPENSSL_ALGO_SHA'.$match[2]);
switch ($match[1]) {
case 'RS':
$format = DataSigner::FORMAT_DER;
break;
case 'ES':
$format = DataSigner::FORMAT_ECDSA;
break;
default:
throw new AcmeCoreClientException(sprintf('The given "%s" algorithm is not supported', $alg));
}
return [$algorithm, $format];
}
}
@@ -0,0 +1,79 @@
<?php
/*
* This file is part of the Acme PHP project.
*
* (c) Titouan Galopin <galopintitouan@gmail.com>
*
* For the full copyright and license information, please view the LICENSE
* file that was distributed with this source code.
*/
namespace AcmePhp\Core\Http;
use AcmePhp\Ssl\KeyPair;
use AcmePhp\Ssl\Parser\KeyParser;
use AcmePhp\Ssl\Signer\DataSigner;
use GuzzleHttp\ClientInterface;
/**
* Guzzle HTTP client wrapper to send requests signed with the account KeyPair.
*
* @author Titouan Galopin <galopintitouan@gmail.com>
*/
class SecureHttpClientFactory
{
/**
* @var ClientInterface
*/
private $httpClient;
/**
* @var Base64SafeEncoder
*/
private $base64Encoder;
/**
* @var KeyParser
*/
private $keyParser;
/**
* @var DataSigner
*/
private $dataSigner;
/**
* @var ServerErrorHandler
*/
private $errorHandler;
public function __construct(
ClientInterface $httpClient,
Base64SafeEncoder $base64Encoder,
KeyParser $keyParser,
DataSigner $dataSigner,
ServerErrorHandler $errorHandler
) {
$this->httpClient = $httpClient;
$this->base64Encoder = $base64Encoder;
$this->keyParser = $keyParser;
$this->dataSigner = $dataSigner;
$this->errorHandler = $errorHandler;
}
/**
* Create a SecureHttpClient using a given account KeyPair.
*/
public function createSecureHttpClient(KeyPair $accountKeyPair): SecureHttpClient
{
return new SecureHttpClient(
$accountKeyPair,
$this->httpClient,
$this->base64Encoder,
$this->keyParser,
$this->dataSigner,
$this->errorHandler
);
}
}
+144
View File
@@ -0,0 +1,144 @@
<?php
/*
* This file is part of the Acme PHP project.
*
* (c) Titouan Galopin <galopintitouan@gmail.com>
*
* For the full copyright and license information, please view the LICENSE
* file that was distributed with this source code.
*/
namespace AcmePhp\Core\Http;
use AcmePhp\Core\Exception\AcmeCoreServerException;
use AcmePhp\Core\Exception\Server\BadCsrServerException;
use AcmePhp\Core\Exception\Server\BadNonceServerException;
use AcmePhp\Core\Exception\Server\CaaServerException;
use AcmePhp\Core\Exception\Server\ConnectionServerException;
use AcmePhp\Core\Exception\Server\DnsServerException;
use AcmePhp\Core\Exception\Server\IncorrectResponseServerException;
use AcmePhp\Core\Exception\Server\InternalServerException;
use AcmePhp\Core\Exception\Server\InvalidContactServerException;
use AcmePhp\Core\Exception\Server\InvalidEmailServerException;
use AcmePhp\Core\Exception\Server\MalformedServerException;
use AcmePhp\Core\Exception\Server\OrderNotReadyServerException;
use AcmePhp\Core\Exception\Server\RateLimitedServerException;
use AcmePhp\Core\Exception\Server\RejectedIdentifierServerException;
use AcmePhp\Core\Exception\Server\TlsServerException;
use AcmePhp\Core\Exception\Server\UnauthorizedServerException;
use AcmePhp\Core\Exception\Server\UnknownHostServerException;
use AcmePhp\Core\Exception\Server\UnsupportedContactServerException;
use AcmePhp\Core\Exception\Server\UnsupportedIdentifierServerException;
use AcmePhp\Core\Exception\Server\UserActionRequiredServerException;
use AcmePhp\Core\Util\JsonDecoder;
use GuzzleHttp\Exception\RequestException;
use GuzzleHttp\Psr7\Utils;
use Psr\Http\Message\RequestInterface;
use Psr\Http\Message\ResponseInterface;
/**
* Create appropriate exception for given server response.
*
* @author Titouan Galopin <galopintitouan@gmail.com>
*/
class ServerErrorHandler
{
private static $exceptions = [
'badCSR' => BadCsrServerException::class,
'badNonce' => BadNonceServerException::class,
'caa' => CaaServerException::class,
'connection' => ConnectionServerException::class,
'dns' => DnsServerException::class,
'incorrectResponse' => IncorrectResponseServerException::class,
'invalidContact' => InvalidContactServerException::class,
'invalidEmail' => InvalidEmailServerException::class,
'malformed' => MalformedServerException::class,
'orderNotReady' => OrderNotReadyServerException::class,
'rateLimited' => RateLimitedServerException::class,
'rejectedIdentifier' => RejectedIdentifierServerException::class,
'serverInternal' => InternalServerException::class,
'tls' => TlsServerException::class,
'unauthorized' => UnauthorizedServerException::class,
'unknownHost' => UnknownHostServerException::class,
'unsupportedContact' => UnsupportedContactServerException::class,
'unsupportedIdentifier' => UnsupportedIdentifierServerException::class,
'userActionRequired' => UserActionRequiredServerException::class,
];
/**
* Get a response summary (useful for exceptions).
* Use Guzzle method if available (Guzzle 6.1.1+).
*/
public static function getResponseBodySummary(ResponseInterface $response): string
{
// Rewind the stream if possible to allow re-reading for the summary.
if ($response->getBody()->isSeekable()) {
$response->getBody()->rewind();
}
if (method_exists(RequestException::class, 'getResponseBodySummary')) {
return RequestException::getResponseBodySummary($response);
}
$body = Utils::copyToString($response->getBody());
if (\strlen($body) > 120) {
return substr($body, 0, 120).' (truncated...)';
}
return $body;
}
public function createAcmeExceptionForResponse(
RequestInterface $request,
ResponseInterface $response,
\Exception $previous = null
): AcmeCoreServerException {
$body = Utils::copyToString($response->getBody());
try {
$data = JsonDecoder::decode($body, true);
} catch (\InvalidArgumentException $e) {
$data = null;
}
if (!$data || !isset($data['type'], $data['detail'])) {
// Not JSON: not an ACME error response
return $this->createDefaultExceptionForResponse($request, $response, $previous);
}
$type = preg_replace('/^urn:(ietf:params:)?acme:error:/i', '', $data['type']);
if (!isset(self::$exceptions[$type])) {
// Unknown type: not an ACME error response
return $this->createDefaultExceptionForResponse($request, $response, $previous);
}
$exceptionClass = self::$exceptions[$type];
return new $exceptionClass(
$request,
sprintf('%s (on request "%s %s")', $data['detail'], $request->getMethod(), $request->getUri()),
$previous
);
}
private function createDefaultExceptionForResponse(
RequestInterface $request,
ResponseInterface $response,
\Exception $previous = null
): AcmeCoreServerException {
return new AcmeCoreServerException(
$request,
sprintf(
'A non-ACME %s HTTP error occured on request "%s %s" (response body: "%s")',
$response->getStatusCode(),
$request->getMethod(),
$request->getUri(),
self::getResponseBodySummary($response)
),
$previous
);
}
}
+22
View File
@@ -0,0 +1,22 @@
Copyright (c) 2016 Titouan Galopin
Permission is hereby granted, free of charge, to any person
obtaining a copy of this software and associated documentation
files (the "Software"), to deal in the Software without
restriction, including without limitation the rights to use,
copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the
Software is furnished to do so, subject to the following
conditions:
The above copyright notice and this permission notice shall be
included in all copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND,
EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES
OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND
NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT
HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY,
WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING
FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR
OTHER DEALINGS IN THE SOFTWARE.
@@ -0,0 +1,112 @@
<?php
/*
* This file is part of the Acme PHP project.
*
* (c) Titouan Galopin <galopintitouan@gmail.com>
*
* For the full copyright and license information, please view the LICENSE
* file that was distributed with this source code.
*/
namespace AcmePhp\Core\Protocol;
/**
* Represent a ACME challenge.
*
* @author Titouan Galopin <galopintitouan@gmail.com>
*/
class AuthorizationChallenge
{
/** @var string */
private $domain;
/** @var string */
private $status;
/** @var string */
private $type;
/** @var string */
private $url;
/** @var string */
private $token;
/** @var string */
private $payload;
public function __construct(string $domain, string $status, string $type, string $url, string $token, string $payload)
{
$this->domain = $domain;
$this->status = $status;
$this->type = $type;
$this->url = $url;
$this->token = $token;
$this->payload = $payload;
}
public function toArray(): array
{
return [
'domain' => $this->getDomain(),
'status' => $this->getStatus(),
'type' => $this->getType(),
'url' => $this->getUrl(),
'token' => $this->getToken(),
'payload' => $this->getPayload(),
];
}
public static function fromArray(array $data): self
{
return new self(
$data['domain'],
$data['status'],
$data['type'],
$data['url'],
$data['token'],
$data['payload']
);
}
public function getDomain(): string
{
return $this->domain;
}
public function getStatus(): string
{
return $this->status;
}
public function isValid(): bool
{
return 'valid' === $this->status;
}
public function isPending(): bool
{
return 'pending' === $this->status || 'processing' === $this->status;
}
public function getType(): string
{
return $this->type;
}
public function getUrl(): string
{
return $this->url;
}
public function getToken(): string
{
return $this->token;
}
public function getPayload(): string
{
return $this->payload;
}
}
+102
View File
@@ -0,0 +1,102 @@
<?php
/*
* This file is part of the Acme PHP project.
*
* (c) Titouan Galopin <galopintitouan@gmail.com>
*
* For the full copyright and license information, please view the LICENSE
* file that was distributed with this source code.
*/
namespace AcmePhp\Core\Protocol;
use AcmePhp\Core\Exception\AcmeCoreClientException;
/**
* Represent an ACME order.
*
* @author Jérémy Derussé <jeremy@derusse.com>
*/
class CertificateOrder
{
/** @var AuthorizationChallenge[][] */
private $authorizationsChallenges;
/** @var string */
private $orderEndpoint;
/** @var string */
private $status;
public function __construct(array $authorizationsChallenges, string $orderEndpoint = null, string $status = null)
{
foreach ($authorizationsChallenges as &$authorizationChallenges) {
foreach ($authorizationChallenges as &$authorizationChallenge) {
if (\is_array($authorizationChallenge)) {
$authorizationChallenge = AuthorizationChallenge::fromArray($authorizationChallenge);
}
}
}
$this->authorizationsChallenges = $authorizationsChallenges;
$this->orderEndpoint = $orderEndpoint;
$this->status = $status;
}
public function toArray(): array
{
$authorizationsChallenges = array_map(
function ($challenges): array {
return array_map(
function ($challenge): array {
return $challenge->toArray();
},
$challenges
);
},
$this->getAuthorizationsChallenges()
);
return [
'authorizationsChallenges' => $authorizationsChallenges,
'orderEndpoint' => $this->getOrderEndpoint(),
'status' => $this->getStatus(),
];
}
public static function fromArray(array $data): self
{
return new self($data['authorizationsChallenges'], $data['orderEndpoint']);
}
/**
* @return AuthorizationChallenge[][]
*/
public function getAuthorizationsChallenges()
{
return $this->authorizationsChallenges;
}
/**
* @return AuthorizationChallenge[]
*/
public function getAuthorizationChallenges(string $domain): array
{
if (!isset($this->authorizationsChallenges[$domain])) {
throw new AcmeCoreClientException('The order does not contains any authorization challenge for the domain '.$domain);
}
return $this->authorizationsChallenges[$domain];
}
public function getOrderEndpoint(): string
{
return $this->orderEndpoint;
}
public function getStatus(): string
{
return $this->status;
}
}
+42
View File
@@ -0,0 +1,42 @@
<?php
/*
* This file is part of the Acme PHP project.
*
* (c) Titouan Galopin <galopintitouan@gmail.com>
*
* For the full copyright and license information, please view the LICENSE
* file that was distributed with this source code.
*/
namespace AcmePhp\Core\Protocol;
/**
* Represent an ACME External Account to be used for External Account Binding.
*
* @author Titouan Galopin <galopintitouan@gmail.com>
*/
class ExternalAccount
{
/** @var string */
private $id;
/** @var string */
private $hmacKey;
public function __construct(string $id, string $hmacKey)
{
$this->id = $id;
$this->hmacKey = $hmacKey;
}
public function getId(): string
{
return $this->id;
}
public function getHmacKey(): string
{
return $this->hmacKey;
}
}
@@ -0,0 +1,62 @@
<?php
/*
* This file is part of the Acme PHP project.
*
* (c) Titouan Galopin <galopintitouan@gmail.com>
*
* For the full copyright and license information, please view the LICENSE
* file that was distributed with this source code.
*/
namespace AcmePhp\Core\Protocol;
use Webmozart\Assert\Assert;
/**
* Represent a ACME resources directory.
*
* @author Titouan Galopin <galopintitouan@gmail.com>
*/
class ResourcesDirectory
{
public const NEW_ACCOUNT = 'newAccount';
public const NEW_ORDER = 'newOrder';
public const NEW_NONCE = 'newNonce';
public const REVOKE_CERT = 'revokeCert';
/** @var array */
private $serverResources;
public function __construct(array $serverResources)
{
$this->serverResources = $serverResources;
}
/**
* @return string[]
*/
public static function getResourcesNames(): array
{
return [
self::NEW_ACCOUNT,
self::NEW_ORDER,
self::NEW_NONCE,
self::REVOKE_CERT,
];
}
/**
* Find a resource URL.
*/
public function getResourceUrl(string $resource): string
{
Assert::oneOf(
$resource,
array_keys($this->serverResources),
'Resource type "%s" is not supported by the ACME server (supported: %2$s)'
);
return $this->serverResources[$resource];
}
}
+79
View File
@@ -0,0 +1,79 @@
<?php
/*
* This file is part of the Acme PHP project.
*
* (c) Titouan Galopin <galopintitouan@gmail.com>
*
* For the full copyright and license information, please view the LICENSE
* file that was distributed with this source code.
*/
namespace AcmePhp\Core\Protocol;
use Webmozart\Assert\Assert;
/**
* @url https://github.com/certbot/certbot/blob/c326c021082dede7c3b2bd411cec3aec6dff0ac5/certbot/constants.py#L124
*/
class RevocationReason
{
public const DEFAULT_REASON = self::REASON_UNSPECIFIED;
public const REASON_UNSPECIFIED = 0;
public const REASON_KEY_COMPROMISE = 1;
public const REASON_AFFILLIATION_CHANGED = 3;
public const REASON_SUPERCEDED = 4;
public const REASON_CESSATION_OF_OPERATION = 5;
/** @var int|null */
private $reasonType;
public function __construct(int $reasonType)
{
Assert::oneOf($reasonType, self::getReasons(), 'Revocation reason type "%s" is not supported by the ACME server (supported: %2$s)');
$this->reasonType = $reasonType;
}
public function getReasonType(): int
{
return $this->reasonType;
}
public static function createDefaultReason(): self
{
return new static(self::DEFAULT_REASON);
}
public static function getFormattedReasons(): array
{
$formatted = [];
foreach (self::getReasonLabelMap() as $reason => $label) {
$formatted[] = $reason.' - '.$label;
}
return $formatted;
}
private static function getReasonLabelMap(): array
{
return [
self::REASON_UNSPECIFIED => 'unspecified',
self::REASON_KEY_COMPROMISE => 'key compromise',
self::REASON_AFFILLIATION_CHANGED => 'affiliation changed',
self::REASON_SUPERCEDED => 'superceded',
self::REASON_CESSATION_OF_OPERATION => 'cessation of operation',
];
}
public static function getReasons(): array
{
return [
self::REASON_UNSPECIFIED,
self::REASON_KEY_COMPROMISE,
self::REASON_AFFILLIATION_CHANGED,
self::REASON_SUPERCEDED,
self::REASON_CESSATION_OF_OPERATION,
];
}
}
+35
View File
@@ -0,0 +1,35 @@
Acme PHP Core library
=====================
[![Build Status](https://img.shields.io/travis/acmephp/acmephp/master.svg?style=flat-square)](https://travis-ci.org/acmephp/acmephp)
[![Quality Score](https://img.shields.io/scrutinizer/g/acmephp/acmephp.svg?style=flat-square)](https://scrutinizer-ci.com/g/acmephp/acmephp)
[![StyleCI](https://styleci.io/repos/59910490/shield)](https://styleci.io/repos/59910490)
[![Packagist Version](https://img.shields.io/packagist/v/acmephp/acmephp.svg?style=flat-square)](https://packagist.org/packages/acmephp/acmephp)
[![Software License](https://img.shields.io/badge/license-MIT-brightgreen.svg?style=flat-square)](LICENSE)
Acme PHP Core is the core of the Acme PHP project : it is a basis for the others more
high-level repositories. It consists of a raw implementation of the Let's Encrypt ACME protocol.
> If you want to chat with us or have questions, ping
> @tgalopin or @jderusse on the [Symfony Slack](https://symfony.com/support)!
## When use Acme PHP Core?
You usually will want to use either [the Acme PHP CLI client](https://github.com/acmephp/cli)
or [an implementation for your application framework](https://github.com/acmephp).
However, in some cases, you may want to manage SSL certificates directly from your application.
In these cases, this library will be useful to you.
Acme PHP Core does nothing more than implementing the [Let's Encrypt/ACME protocol](https://github.com/letsencrypt/acme-spec) :
the generated SSL keys and certificates are stored in memory and returned to your script. You are the one in charge
of storing them somewhere persistent.
## Documentation
Read the official [Acme PHP documentation](https://acmephp.github.io).
## Launch the Test suite
The Acme PHP test suite is located in the main repository:
[https://github.com/acmephp/acmephp#launch-the-test-suite](https://github.com/acmephp/acmephp#launch-the-test-suite).
+49
View File
@@ -0,0 +1,49 @@
<?php
/*
* This file is part of the Acme PHP project.
*
* (c) Titouan Galopin <galopintitouan@gmail.com>
*
* For the full copyright and license information, please view the LICENSE
* file that was distributed with this source code.
*/
namespace AcmePhp\Core\Util;
/**
* Guzzle HTTP client wrapper to send requests signed with the account KeyPair.
*
* @author Titouan Galopin <galopintitouan@gmail.com>
*
* @internal
*/
class JsonDecoder
{
/**
* Wrapper for json_decode that throws when an error occurs.
* Extracted from Guzzle for BC.
*
* @param string $json JSON data to parse
* @param bool $assoc when true, returned objects will be converted
* into associative arrays
* @param int $depth user specified recursion depth
* @param int $options bitmask of JSON decode options
*
* @throws \InvalidArgumentException if the JSON cannot be decoded
*
* @return mixed
*
* @see http://www.php.net/manual/en/function.json-decode.php
*/
public static function decode(string $json, bool $assoc = false, int $depth = 512, int $options = 0)
{
$data = json_decode($json, $assoc, $depth, $options);
if (JSON_ERROR_NONE !== json_last_error()) {
throw new \InvalidArgumentException('json_decode error: '.json_last_error_msg());
}
return $data;
}
}
+50
View File
@@ -0,0 +1,50 @@
{
"name": "acmephp/core",
"description": "Raw implementation of the ACME protocol in PHP",
"type": "library",
"license": "MIT",
"homepage": "https://github.com/acmephp/core",
"keywords": [
"acmephp",
"letsencrypt",
"https",
"encryption",
"certificate",
"ssl",
"openssl",
"CSR",
"x509"
],
"authors": [
{
"name": "Titouan Galopin",
"email": "galopintitouan@gmail.com",
"homepage": "http://titouangalopin.com"
},
{
"name": "Jérémy Derussé",
"homepage": "https://twitter.com/jderusse"
}
],
"require": {
"php": ">=7.2.5",
"ext-hash": "*",
"ext-json": "*",
"ext-openssl": "*",
"acmephp/ssl": "^2.0",
"guzzlehttp/guzzle": "^6.0|^7.0",
"guzzlehttp/psr7": "^1.7|^2.1",
"lcobucci/jwt": "^3.3|^4.0",
"psr/http-message": "^1.0",
"psr/log": "^1.0|^2.0|^3.0",
"webmozart/assert": "^1.0"
},
"autoload": {
"psr-4": {
"AcmePhp\\Core\\": "."
}
},
"config": {
"sort-packages": true
}
}
+2
View File
@@ -0,0 +1,2 @@
composer.lock
vendor/
+80
View File
@@ -0,0 +1,80 @@
<?php
/*
* This file is part of the Acme PHP project.
*
* (c) Titouan Galopin <galopintitouan@gmail.com>
*
* For the full copyright and license information, please view the LICENSE
* file that was distributed with this source code.
*/
namespace AcmePhp\Ssl;
use AcmePhp\Ssl\Exception\CertificateFormatException;
use Webmozart\Assert\Assert;
/**
* Represent a Certificate.
*
* @author Jérémy Derussé <jeremy@derusse.com>
*/
class Certificate
{
/** @var string */
private $certificatePEM;
/** @var Certificate */
private $issuerCertificate;
public function __construct(string $certificatePEM, self $issuerCertificate = null)
{
Assert::stringNotEmpty($certificatePEM, __CLASS__.'::$certificatePEM should not be an empty string. Got %s');
$this->certificatePEM = $certificatePEM;
$this->issuerCertificate = $issuerCertificate;
}
/**
* @return Certificate[]
*/
public function getIssuerChain(): array
{
$chain = [];
$issuerCertificate = $this->getIssuerCertificate();
while (null !== $issuerCertificate) {
$chain[] = $issuerCertificate;
$issuerCertificate = $issuerCertificate->getIssuerCertificate();
}
return $chain;
}
public function getPEM(): string
{
return $this->certificatePEM;
}
public function getIssuerCertificate(): ?self
{
return $this->issuerCertificate;
}
/**
* @return resource
*/
public function getPublicKeyResource()
{
if (!$resource = openssl_pkey_get_public($this->certificatePEM)) {
throw new CertificateFormatException(sprintf('Failed to convert certificate into public key resource: %s', openssl_error_string()));
}
return $resource;
}
public function getPublicKey(): PublicKey
{
return new PublicKey(openssl_pkey_get_details($this->getPublicKeyResource())['key']);
}
}
+42
View File
@@ -0,0 +1,42 @@
<?php
/*
* This file is part of the Acme PHP project.
*
* (c) Titouan Galopin <galopintitouan@gmail.com>
*
* For the full copyright and license information, please view the LICENSE
* file that was distributed with this source code.
*/
namespace AcmePhp\Ssl;
/**
* Contains data required to request a certificate.
*
* @author Jérémy Derussé <jeremy@derusse.com>
*/
class CertificateRequest
{
/** @var DistinguishedName */
private $distinguishedName;
/** @var KeyPair */
private $keyPair;
public function __construct(DistinguishedName $distinguishedName, KeyPair $keyPair)
{
$this->distinguishedName = $distinguishedName;
$this->keyPair = $keyPair;
}
public function getDistinguishedName(): DistinguishedName
{
return $this->distinguishedName;
}
public function getKeyPair(): KeyPair
{
return $this->keyPair;
}
}
+42
View File
@@ -0,0 +1,42 @@
<?php
/*
* This file is part of the Acme PHP project.
*
* (c) Titouan Galopin <galopintitouan@gmail.com>
*
* For the full copyright and license information, please view the LICENSE
* file that was distributed with this source code.
*/
namespace AcmePhp\Ssl;
/**
* Represent the response to a certificate request.
*
* @author Jérémy Derussé <jeremy@derusse.com>
*/
class CertificateResponse
{
/** @var CertificateRequest */
private $certificateRequest;
/** @var Certificate */
private $certificate;
public function __construct(CertificateRequest $certificateRequest, Certificate $certificate)
{
$this->certificateRequest = $certificateRequest;
$this->certificate = $certificate;
}
public function getCertificateRequest(): CertificateRequest
{
return $this->certificateRequest;
}
public function getCertificate(): Certificate
{
return $this->certificate;
}
}
+112
View File
@@ -0,0 +1,112 @@
<?php
/*
* This file is part of the Acme PHP project.
*
* (c) Titouan Galopin <galopintitouan@gmail.com>
*
* For the full copyright and license information, please view the LICENSE
* file that was distributed with this source code.
*/
namespace AcmePhp\Ssl;
use Webmozart\Assert\Assert;
/**
* Represent a Distinguished Name.
*
* @author Jérémy Derussé <jeremy@derusse.com>
*/
class DistinguishedName
{
/** @var string */
private $commonName;
/** @var string */
private $countryName;
/** @var string */
private $stateOrProvinceName;
/** @var string */
private $localityName;
/** @var string */
private $organizationName;
/** @var string */
private $organizationalUnitName;
/** @var string */
private $emailAddress;
/** @var array */
private $subjectAlternativeNames;
public function __construct(
string $commonName,
string $countryName = null,
string $stateOrProvinceName = null,
string $localityName = null,
string $organizationName = null,
string $organizationalUnitName = null,
string $emailAddress = null,
array $subjectAlternativeNames = []
) {
Assert::stringNotEmpty($commonName, __CLASS__.'::$commonName expected a non empty string. Got: %s');
Assert::allStringNotEmpty(
$subjectAlternativeNames,
__CLASS__.'::$subjectAlternativeNames expected an array of non empty string. Got: %s'
);
$this->commonName = $commonName;
$this->countryName = $countryName;
$this->stateOrProvinceName = $stateOrProvinceName;
$this->localityName = $localityName;
$this->organizationName = $organizationName;
$this->organizationalUnitName = $organizationalUnitName;
$this->emailAddress = $emailAddress;
$this->subjectAlternativeNames = array_diff(array_unique($subjectAlternativeNames), [$commonName]);
}
public function getCommonName(): string
{
return $this->commonName;
}
public function getCountryName(): ?string
{
return $this->countryName;
}
public function getStateOrProvinceName(): ?string
{
return $this->stateOrProvinceName;
}
public function getLocalityName(): ?string
{
return $this->localityName;
}
public function getOrganizationName(): ?string
{
return $this->organizationName;
}
public function getOrganizationalUnitName(): ?string
{
return $this->organizationalUnitName;
}
public function getEmailAddress(): ?string
{
return $this->emailAddress;
}
public function getSubjectAlternativeNames(): array
{
return $this->subjectAlternativeNames;
}
}
+19
View File
@@ -0,0 +1,19 @@
<?php
/*
* This file is part of the Acme PHP project.
*
* (c) Titouan Galopin <galopintitouan@gmail.com>
*
* For the full copyright and license information, please view the LICENSE
* file that was distributed with this source code.
*/
namespace AcmePhp\Ssl\Exception;
/**
* @author Jérémy Derussé <jeremy@derusse.com>
*/
class AcmeSslException extends \RuntimeException
{
}
@@ -0,0 +1,19 @@
<?php
/*
* This file is part of the Acme PHP project.
*
* (c) Titouan Galopin <galopintitouan@gmail.com>
*
* For the full copyright and license information, please view the LICENSE
* file that was distributed with this source code.
*/
namespace AcmePhp\Ssl\Exception;
/**
* @author Jérémy Derussé <jeremy@derusse.com>
*/
class CSRSigningException extends SigningException
{
}
@@ -0,0 +1,19 @@
<?php
/*
* This file is part of the Acme PHP project.
*
* (c) Titouan Galopin <galopintitouan@gmail.com>
*
* For the full copyright and license information, please view the LICENSE
* file that was distributed with this source code.
*/
namespace AcmePhp\Ssl\Exception;
/**
* @author Jérémy Derussé <jeremy@derusse.com>
*/
class CertificateFormatException extends ParsingException
{
}
@@ -0,0 +1,19 @@
<?php
/*
* This file is part of the Acme PHP project.
*
* (c) Titouan Galopin <galopintitouan@gmail.com>
*
* For the full copyright and license information, please view the LICENSE
* file that was distributed with this source code.
*/
namespace AcmePhp\Ssl\Exception;
/**
* @author Jérémy Derussé <jeremy@derusse.com>
*/
class CertificateParsingException extends ParsingException
{
}
@@ -0,0 +1,19 @@
<?php
/*
* This file is part of the Acme PHP project.
*
* (c) Titouan Galopin <galopintitouan@gmail.com>
*
* For the full copyright and license information, please view the LICENSE
* file that was distributed with this source code.
*/
namespace AcmePhp\Ssl\Exception;
/**
* @author Titouan Galopin <galopintitouan@gmail.com>
*/
class DataSigningException extends SigningException
{
}
@@ -0,0 +1,19 @@
<?php
/*
* This file is part of the Acme PHP project.
*
* (c) Titouan Galopin <galopintitouan@gmail.com>
*
* For the full copyright and license information, please view the LICENSE
* file that was distributed with this source code.
*/
namespace AcmePhp\Ssl\Exception;
/**
* @author Jérémy Derussé <jeremy@derusse.com>
*/
class KeyFormatException extends ParsingException
{
}
@@ -0,0 +1,19 @@
<?php
/*
* This file is part of the Acme PHP project.
*
* (c) Titouan Galopin <galopintitouan@gmail.com>
*
* For the full copyright and license information, please view the LICENSE
* file that was distributed with this source code.
*/
namespace AcmePhp\Ssl\Exception;
/**
* @author Jérémy Derussé <jeremy@derusse.com>
*/
class KeyGenerationException extends AcmeSslException
{
}
@@ -0,0 +1,19 @@
<?php
/*
* This file is part of the Acme PHP project.
*
* (c) Titouan Galopin <galopintitouan@gmail.com>
*
* For the full copyright and license information, please view the LICENSE
* file that was distributed with this source code.
*/
namespace AcmePhp\Ssl\Exception;
/**
* @author Jérémy Derussé <jeremy@derusse.com>
*/
class KeyPairGenerationException extends KeyGenerationException
{
}
@@ -0,0 +1,19 @@
<?php
/*
* This file is part of the Acme PHP project.
*
* (c) Titouan Galopin <galopintitouan@gmail.com>
*
* For the full copyright and license information, please view the LICENSE
* file that was distributed with this source code.
*/
namespace AcmePhp\Ssl\Exception;
/**
* @author Titouan Galopin <galopintitouan@gmail.com>
*/
class KeyParsingException extends ParsingException
{
}
+19
View File
@@ -0,0 +1,19 @@
<?php
/*
* This file is part of the Acme PHP project.
*
* (c) Titouan Galopin <galopintitouan@gmail.com>
*
* For the full copyright and license information, please view the LICENSE
* file that was distributed with this source code.
*/
namespace AcmePhp\Ssl\Exception;
/**
* @author Titouan Galopin <galopintitouan@gmail.com>
*/
class ParsingException extends AcmeSslException
{
}
+19
View File
@@ -0,0 +1,19 @@
<?php
/*
* This file is part of the Acme PHP project.
*
* (c) Titouan Galopin <galopintitouan@gmail.com>
*
* For the full copyright and license information, please view the LICENSE
* file that was distributed with this source code.
*/
namespace AcmePhp\Ssl\Exception;
/**
* @author Titouan Galopin <galopintitouan@gmail.com>
*/
class SigningException extends AcmeSslException
{
}
@@ -0,0 +1,55 @@
<?php
/*
* This file is part of the Acme PHP project.
*
* (c) Titouan Galopin <galopintitouan@gmail.com>
*
* For the full copyright and license information, please view the LICENSE
* file that was distributed with this source code.
*/
namespace AcmePhp\Ssl\Generator;
use AcmePhp\Ssl\PrivateKey;
/**
* Generate random RSA private key using OpenSSL.
*
* @author Jérémy Derussé <jeremy@derusse.com>
*/
class ChainPrivateKeyGenerator implements PrivateKeyGeneratorInterface
{
/** @var PrivateKeyGeneratorInterface[] */
private $generators;
/**
* @param PrivateKeyGeneratorInterface[] $generators
*/
public function __construct(iterable $generators)
{
$this->generators = $generators;
}
public function generatePrivateKey(KeyOption $keyOption): PrivateKey
{
foreach ($this->generators as $generator) {
if ($generator->supportsKeyOption($keyOption)) {
return $generator->generatePrivateKey($keyOption);
}
}
throw new \LogicException(sprintf('Unable to find a generator for a key option of type %s', \get_class($keyOption)));
}
public function supportsKeyOption(KeyOption $keyOption): bool
{
foreach ($this->generators as $generator) {
if ($generator->supportsKeyOption($keyOption)) {
return true;
}
}
return false;
}
}
@@ -0,0 +1,46 @@
<?php
/*
* This file is part of the Acme PHP project.
*
* (c) Titouan Galopin <galopintitouan@gmail.com>
*
* For the full copyright and license information, please view the LICENSE
* file that was distributed with this source code.
*/
namespace AcmePhp\Ssl\Generator\DhKey;
use AcmePhp\Ssl\Generator\KeyOption;
use AcmePhp\Ssl\Generator\OpensslPrivateKeyGeneratorTrait;
use AcmePhp\Ssl\Generator\PrivateKeyGeneratorInterface;
use AcmePhp\Ssl\PrivateKey;
use Webmozart\Assert\Assert;
/**
* Generate random DH private key using OpenSSL.
*
* @author Jérémy Derussé <jeremy@derusse.com>
*/
class DhKeyGenerator implements PrivateKeyGeneratorInterface
{
use OpensslPrivateKeyGeneratorTrait;
public function generatePrivateKey(KeyOption $keyOption): PrivateKey
{
Assert::isInstanceOf($keyOption, DhKeyOption::class);
return $this->generatePrivateKeyFromOpensslOptions([
'private_key_type' => OPENSSL_KEYTYPE_DH,
'dh' => [
'p' => $keyOption->getPrime(),
'g' => $keyOption->getGenerator(),
],
]);
}
public function supportsKeyOption(KeyOption $keyOption): bool
{
return $keyOption instanceof DhKeyOption;
}
}
@@ -0,0 +1,45 @@
<?php
/*
* This file is part of the Acme PHP project.
*
* (c) Titouan Galopin <galopintitouan@gmail.com>
*
* For the full copyright and license information, please view the LICENSE
* file that was distributed with this source code.
*/
namespace AcmePhp\Ssl\Generator\DhKey;
use AcmePhp\Ssl\Generator\KeyOption;
class DhKeyOption implements KeyOption
{
/** @var string */
private $generator;
/** @var string */
private $prime;
/**
* @param string $prime Hexadecimal representation of the prime
* @param string $generator Hexadecimal representation of the generator: ie. 02
*
* @see https://tools.ietf.org/html/rfc3526 how to choose a prime and generator numbers
*/
public function __construct(string $prime, string $generator = '02')
{
$this->generator = pack('H*', $generator);
$this->prime = pack('H*', $prime);
}
public function getGenerator(): string
{
return $this->generator;
}
public function getPrime(): string
{
return $this->prime;
}
}
@@ -0,0 +1,43 @@
<?php
/*
* This file is part of the Acme PHP project.
*
* (c) Titouan Galopin <galopintitouan@gmail.com>
*
* For the full copyright and license information, please view the LICENSE
* file that was distributed with this source code.
*/
namespace AcmePhp\Ssl\Generator\DsaKey;
use AcmePhp\Ssl\Generator\KeyOption;
use AcmePhp\Ssl\Generator\OpensslPrivateKeyGeneratorTrait;
use AcmePhp\Ssl\Generator\PrivateKeyGeneratorInterface;
use AcmePhp\Ssl\PrivateKey;
use Webmozart\Assert\Assert;
/**
* Generate random DSA private key using OpenSSL.
*
* @author Jérémy Derussé <jeremy@derusse.com>
*/
class DsaKeyGenerator implements PrivateKeyGeneratorInterface
{
use OpensslPrivateKeyGeneratorTrait;
public function generatePrivateKey(KeyOption $keyOption): PrivateKey
{
Assert::isInstanceOf($keyOption, DsaKeyOption::class);
return $this->generatePrivateKeyFromOpensslOptions([
'private_key_type' => OPENSSL_KEYTYPE_DSA,
'private_key_bits' => $keyOption->getBits(),
]);
}
public function supportsKeyOption(KeyOption $keyOption): bool
{
return $keyOption instanceof DsaKeyOption;
}
}
@@ -0,0 +1,30 @@
<?php
/*
* This file is part of the Acme PHP project.
*
* (c) Titouan Galopin <galopintitouan@gmail.com>
*
* For the full copyright and license information, please view the LICENSE
* file that was distributed with this source code.
*/
namespace AcmePhp\Ssl\Generator\DsaKey;
use AcmePhp\Ssl\Generator\KeyOption;
class DsaKeyOption implements KeyOption
{
/** @var int */
private $bits;
public function __construct(int $bits = 2048)
{
$this->bits = $bits;
}
public function getBits(): int
{
return $this->bits;
}
}
@@ -0,0 +1,43 @@
<?php
/*
* This file is part of the Acme PHP project.
*
* (c) Titouan Galopin <galopintitouan@gmail.com>
*
* For the full copyright and license information, please view the LICENSE
* file that was distributed with this source code.
*/
namespace AcmePhp\Ssl\Generator\EcKey;
use AcmePhp\Ssl\Generator\KeyOption;
use AcmePhp\Ssl\Generator\OpensslPrivateKeyGeneratorTrait;
use AcmePhp\Ssl\Generator\PrivateKeyGeneratorInterface;
use AcmePhp\Ssl\PrivateKey;
use Webmozart\Assert\Assert;
/**
* Generate random EC private key using OpenSSL.
*
* @author Jérémy Derussé <jeremy@derusse.com>
*/
class EcKeyGenerator implements PrivateKeyGeneratorInterface
{
use OpensslPrivateKeyGeneratorTrait;
public function generatePrivateKey(KeyOption $keyOption): PrivateKey
{
Assert::isInstanceOf($keyOption, EcKeyOption::class);
return $this->generatePrivateKeyFromOpensslOptions([
'private_key_type' => OPENSSL_KEYTYPE_EC,
'curve_name' => $keyOption->getCurveName(),
]);
}
public function supportsKeyOption(KeyOption $keyOption): bool
{
return $keyOption instanceof EcKeyOption;
}
}
@@ -0,0 +1,33 @@
<?php
/*
* This file is part of the Acme PHP project.
*
* (c) Titouan Galopin <galopintitouan@gmail.com>
*
* For the full copyright and license information, please view the LICENSE
* file that was distributed with this source code.
*/
namespace AcmePhp\Ssl\Generator\EcKey;
use AcmePhp\Ssl\Generator\KeyOption;
use Webmozart\Assert\Assert;
class EcKeyOption implements KeyOption
{
/** @var string */
private $curveName;
public function __construct(string $curveName = 'secp384r1')
{
Assert::oneOf($curveName, openssl_get_curve_names(), 'The given curve %s is not supported. Available curves are: %s');
$this->curveName = $curveName;
}
public function getCurveName(): string
{
return $this->curveName;
}
}
+16
View File
@@ -0,0 +1,16 @@
<?php
/*
* This file is part of the Acme PHP project.
*
* (c) Titouan Galopin <galopintitouan@gmail.com>
*
* For the full copyright and license information, please view the LICENSE
* file that was distributed with this source code.
*/
namespace AcmePhp\Ssl\Generator;
interface KeyOption
{
}
+66
View File
@@ -0,0 +1,66 @@
<?php
/*
* This file is part of the Acme PHP project.
*
* (c) Titouan Galopin <galopintitouan@gmail.com>
*
* For the full copyright and license information, please view the LICENSE
* file that was distributed with this source code.
*/
namespace AcmePhp\Ssl\Generator;
use AcmePhp\Ssl\Exception\KeyGenerationException;
use AcmePhp\Ssl\Exception\KeyPairGenerationException;
use AcmePhp\Ssl\Generator\DhKey\DhKeyGenerator;
use AcmePhp\Ssl\Generator\DsaKey\DsaKeyGenerator;
use AcmePhp\Ssl\Generator\EcKey\EcKeyGenerator;
use AcmePhp\Ssl\Generator\RsaKey\RsaKeyGenerator;
use AcmePhp\Ssl\Generator\RsaKey\RsaKeyOption;
use AcmePhp\Ssl\KeyPair;
/**
* Generate random KeyPair using OpenSSL.
*
* @author Jérémy Derussé <jeremy@derusse.com>
*/
class KeyPairGenerator
{
private $generator;
public function __construct(PrivateKeyGeneratorInterface $generator = null)
{
$this->generator = $generator ?: new ChainPrivateKeyGenerator(
[
new RsaKeyGenerator(),
new EcKeyGenerator(),
new DhKeyGenerator(),
new DsaKeyGenerator(),
]
);
}
/**
* @param KeyOption|null $keyOption configuration of the key to generate
*
* @throws KeyPairGenerationException when OpenSSL failed to generate keys
*/
public function generateKeyPair(KeyOption $keyOption = null): KeyPair
{
if (null === $keyOption) {
$keyOption = new RsaKeyOption();
}
try {
$privateKey = $this->generator->generatePrivateKey($keyOption);
} catch (KeyGenerationException $e) {
throw new KeyPairGenerationException('Fail to generate a KeyPair with the given options', 0, $e);
}
return new KeyPair(
$privateKey->getPublicKey(),
$privateKey
);
}
}
@@ -0,0 +1,38 @@
<?php
/*
* This file is part of the Acme PHP project.
*
* (c) Titouan Galopin <galopintitouan@gmail.com>
*
* For the full copyright and license information, please view the LICENSE
* file that was distributed with this source code.
*/
namespace AcmePhp\Ssl\Generator;
use AcmePhp\Ssl\Exception\KeyGenerationException;
use AcmePhp\Ssl\Exception\KeyPairGenerationException;
use AcmePhp\Ssl\PrivateKey;
trait OpensslPrivateKeyGeneratorTrait
{
private function generatePrivateKeyFromOpensslOptions(array $opensslOptions): PrivateKey
{
$resource = openssl_pkey_new($opensslOptions);
if (!$resource) {
throw new KeyGenerationException(sprintf('OpenSSL key creation failed during generation with error: %s', openssl_error_string()));
}
if (!openssl_pkey_export($resource, $privateKey)) {
throw new KeyPairGenerationException(sprintf('OpenSSL key export failed during generation with error: %s', openssl_error_string()));
}
// PHP 8 automatically frees the key instance and deprecates the function
if (\PHP_VERSION_ID < 80000) {
openssl_free_key($resource);
}
return new PrivateKey($privateKey);
}
}

Some files were not shown because too many files have changed in this diff Show More