From 20d7810687ef53b2c123c2f337ac8ce71e539fda Mon Sep 17 00:00:00 2001 From: Daan Raatjes Date: Sun, 22 May 2022 18:01:57 +0200 Subject: [PATCH 1/5] Self contain Acme package --- composer.json | 12 +- src/AcmePhp/Core/AcmeClient.php | 374 ++++++++++++++ src/AcmePhp/Core/AcmeClientInterface.php | 127 +++++ src/AcmePhp/Core/AcmeClientV2Interface.php | 81 ++++ .../Exception/AcmeCoreClientException.php | 25 + .../Core/Exception/AcmeCoreException.php | 19 + .../Exception/AcmeCoreServerException.php | 27 ++ .../Exception/AcmeDnsResolutionException.php | 23 + .../CertificateRequestFailedException.php | 23 + .../CertificateRequestTimedOutException.php | 23 + .../CertificateRevocationException.php | 18 + .../Protocol/ChallengeFailedException.php | 38 ++ .../ChallengeNotSupportedException.php | 23 + .../Protocol/ChallengeTimedOutException.php | 38 ++ .../Protocol/ExpectedJsonException.php | 19 + .../Exception/Protocol/ProtocolException.php | 23 + .../Server/BadCsrServerException.php | 30 ++ .../Server/BadNonceServerException.php | 30 ++ .../Exception/Server/CaaServerException.php | 30 ++ .../Server/ConnectionServerException.php | 30 ++ .../Exception/Server/DnsServerException.php | 30 ++ .../IncorrectResponseServerException.php | 30 ++ .../Server/InternalServerException.php | 30 ++ .../Server/InvalidContactServerException.php | 30 ++ .../Server/InvalidEmailServerException.php | 30 ++ .../Server/MalformedServerException.php | 30 ++ .../Server/OrderNotReadyServerException.php | 27 ++ .../Server/RateLimitedServerException.php | 30 ++ .../RejectedIdentifierServerException.php | 30 ++ .../Exception/Server/TlsServerException.php | 30 ++ .../Server/UnauthorizedServerException.php | 30 ++ .../Server/UnknownHostServerException.php | 30 ++ .../UnsupportedContactServerException.php | 30 ++ .../UnsupportedIdentifierServerException.php | 30 ++ .../UserActionRequiredServerException.php | 30 ++ src/AcmePhp/Core/Http/Base64SafeEncoder.php | 47 ++ src/AcmePhp/Core/Http/SecureHttpClient.php | 455 ++++++++++++++++++ .../Core/Http/SecureHttpClientFactory.php | 81 ++++ src/AcmePhp/Core/Http/ServerErrorHandler.php | 152 ++++++ .../Core/Protocol/AuthorizationChallenge.php | 171 +++++++ .../Core/Protocol/CertificateOrder.php | 110 +++++ .../Core/Protocol/ResourcesDirectory.php | 68 +++ .../Core/Protocol/RevocationReason.php | 104 ++++ src/AcmePhp/Core/Util/JsonDecoder.php | 49 ++ src/AcmePhp/Ssl/Certificate.php | 93 ++++ src/AcmePhp/Ssl/CertificateRequest.php | 48 ++ src/AcmePhp/Ssl/CertificateResponse.php | 50 ++ src/AcmePhp/Ssl/DistinguishedName.php | 151 ++++++ .../Ssl/Exception/AcmeSslException.php | 19 + .../Ssl/Exception/CSRSigningException.php | 19 + .../Exception/CertificateFormatException.php | 19 + .../Exception/CertificateParsingException.php | 19 + .../Ssl/Exception/DataSigningException.php | 19 + .../Ssl/Exception/KeyFormatException.php | 19 + .../Ssl/Exception/KeyGenerationException.php | 19 + .../Exception/KeyPairGenerationException.php | 19 + .../Ssl/Exception/KeyParsingException.php | 19 + .../Ssl/Exception/ParsingException.php | 19 + .../Ssl/Exception/SigningException.php | 19 + .../Generator/ChainPrivateKeyGenerator.php | 53 ++ .../Ssl/Generator/DhKey/DhKeyGenerator.php | 50 ++ .../Ssl/Generator/DhKey/DhKeyOption.php | 50 ++ .../Ssl/Generator/DsaKey/DsaKeyGenerator.php | 47 ++ .../Ssl/Generator/DsaKey/DsaKeyOption.php | 36 ++ .../Ssl/Generator/EcKey/EcKeyGenerator.php | 51 ++ .../Ssl/Generator/EcKey/EcKeyOption.php | 41 ++ src/AcmePhp/Ssl/Generator/KeyOption.php | 16 + .../Ssl/Generator/KeyPairGenerator.php | 76 +++ .../OpensslPrivateKeyGeneratorTrait.php | 38 ++ .../PrivateKeyGeneratorInterface.php | 43 ++ .../Ssl/Generator/RsaKey/RsaKeyGenerator.php | 47 ++ .../Ssl/Generator/RsaKey/RsaKeyOption.php | 36 ++ src/AcmePhp/Ssl/Key.php | 62 +++ src/AcmePhp/Ssl/KeyPair.php | 48 ++ src/AcmePhp/Ssl/ParsedCertificate.php | 155 ++++++ src/AcmePhp/Ssl/ParsedKey.php | 123 +++++ src/AcmePhp/Ssl/Parser/CertificateParser.php | 81 ++++ src/AcmePhp/Ssl/Parser/KeyParser.php | 70 +++ src/AcmePhp/Ssl/PrivateKey.php | 71 +++ src/AcmePhp/Ssl/PublicKey.php | 61 +++ .../Ssl/Signer/CertificateRequestSigner.php | 136 ++++++ src/AcmePhp/Ssl/Signer/DataSigner.php | 137 ++++++ src/Jobs/ChallengeAuthorization.php | 2 +- src/Jobs/CleanUpChallenge.php | 4 +- src/Jobs/RequestAuthorization.php | 2 +- src/Jobs/RequestCertificate.php | 6 +- src/Jobs/StoreCertificate.php | 4 +- src/LetsEncrypt.php | 14 +- src/LetsEncryptServiceProvider.php | 10 +- src/PendingCertificate.php | 4 +- tests/Models/LetsEncryptCertificateTest.php | 7 +- 91 files changed, 4794 insertions(+), 35 deletions(-) create mode 100644 src/AcmePhp/Core/AcmeClient.php create mode 100644 src/AcmePhp/Core/AcmeClientInterface.php create mode 100644 src/AcmePhp/Core/AcmeClientV2Interface.php create mode 100644 src/AcmePhp/Core/Exception/AcmeCoreClientException.php create mode 100644 src/AcmePhp/Core/Exception/AcmeCoreException.php create mode 100644 src/AcmePhp/Core/Exception/AcmeCoreServerException.php create mode 100644 src/AcmePhp/Core/Exception/AcmeDnsResolutionException.php create mode 100644 src/AcmePhp/Core/Exception/Protocol/CertificateRequestFailedException.php create mode 100644 src/AcmePhp/Core/Exception/Protocol/CertificateRequestTimedOutException.php create mode 100644 src/AcmePhp/Core/Exception/Protocol/CertificateRevocationException.php create mode 100644 src/AcmePhp/Core/Exception/Protocol/ChallengeFailedException.php create mode 100644 src/AcmePhp/Core/Exception/Protocol/ChallengeNotSupportedException.php create mode 100644 src/AcmePhp/Core/Exception/Protocol/ChallengeTimedOutException.php create mode 100644 src/AcmePhp/Core/Exception/Protocol/ExpectedJsonException.php create mode 100644 src/AcmePhp/Core/Exception/Protocol/ProtocolException.php create mode 100644 src/AcmePhp/Core/Exception/Server/BadCsrServerException.php create mode 100644 src/AcmePhp/Core/Exception/Server/BadNonceServerException.php create mode 100644 src/AcmePhp/Core/Exception/Server/CaaServerException.php create mode 100644 src/AcmePhp/Core/Exception/Server/ConnectionServerException.php create mode 100644 src/AcmePhp/Core/Exception/Server/DnsServerException.php create mode 100644 src/AcmePhp/Core/Exception/Server/IncorrectResponseServerException.php create mode 100644 src/AcmePhp/Core/Exception/Server/InternalServerException.php create mode 100644 src/AcmePhp/Core/Exception/Server/InvalidContactServerException.php create mode 100644 src/AcmePhp/Core/Exception/Server/InvalidEmailServerException.php create mode 100644 src/AcmePhp/Core/Exception/Server/MalformedServerException.php create mode 100644 src/AcmePhp/Core/Exception/Server/OrderNotReadyServerException.php create mode 100644 src/AcmePhp/Core/Exception/Server/RateLimitedServerException.php create mode 100644 src/AcmePhp/Core/Exception/Server/RejectedIdentifierServerException.php create mode 100644 src/AcmePhp/Core/Exception/Server/TlsServerException.php create mode 100644 src/AcmePhp/Core/Exception/Server/UnauthorizedServerException.php create mode 100644 src/AcmePhp/Core/Exception/Server/UnknownHostServerException.php create mode 100644 src/AcmePhp/Core/Exception/Server/UnsupportedContactServerException.php create mode 100644 src/AcmePhp/Core/Exception/Server/UnsupportedIdentifierServerException.php create mode 100644 src/AcmePhp/Core/Exception/Server/UserActionRequiredServerException.php create mode 100644 src/AcmePhp/Core/Http/Base64SafeEncoder.php create mode 100644 src/AcmePhp/Core/Http/SecureHttpClient.php create mode 100644 src/AcmePhp/Core/Http/SecureHttpClientFactory.php create mode 100644 src/AcmePhp/Core/Http/ServerErrorHandler.php create mode 100644 src/AcmePhp/Core/Protocol/AuthorizationChallenge.php create mode 100644 src/AcmePhp/Core/Protocol/CertificateOrder.php create mode 100644 src/AcmePhp/Core/Protocol/ResourcesDirectory.php create mode 100644 src/AcmePhp/Core/Protocol/RevocationReason.php create mode 100644 src/AcmePhp/Core/Util/JsonDecoder.php create mode 100644 src/AcmePhp/Ssl/Certificate.php create mode 100644 src/AcmePhp/Ssl/CertificateRequest.php create mode 100644 src/AcmePhp/Ssl/CertificateResponse.php create mode 100644 src/AcmePhp/Ssl/DistinguishedName.php create mode 100644 src/AcmePhp/Ssl/Exception/AcmeSslException.php create mode 100644 src/AcmePhp/Ssl/Exception/CSRSigningException.php create mode 100644 src/AcmePhp/Ssl/Exception/CertificateFormatException.php create mode 100644 src/AcmePhp/Ssl/Exception/CertificateParsingException.php create mode 100644 src/AcmePhp/Ssl/Exception/DataSigningException.php create mode 100644 src/AcmePhp/Ssl/Exception/KeyFormatException.php create mode 100644 src/AcmePhp/Ssl/Exception/KeyGenerationException.php create mode 100644 src/AcmePhp/Ssl/Exception/KeyPairGenerationException.php create mode 100644 src/AcmePhp/Ssl/Exception/KeyParsingException.php create mode 100644 src/AcmePhp/Ssl/Exception/ParsingException.php create mode 100644 src/AcmePhp/Ssl/Exception/SigningException.php create mode 100644 src/AcmePhp/Ssl/Generator/ChainPrivateKeyGenerator.php create mode 100644 src/AcmePhp/Ssl/Generator/DhKey/DhKeyGenerator.php create mode 100644 src/AcmePhp/Ssl/Generator/DhKey/DhKeyOption.php create mode 100644 src/AcmePhp/Ssl/Generator/DsaKey/DsaKeyGenerator.php create mode 100644 src/AcmePhp/Ssl/Generator/DsaKey/DsaKeyOption.php create mode 100644 src/AcmePhp/Ssl/Generator/EcKey/EcKeyGenerator.php create mode 100644 src/AcmePhp/Ssl/Generator/EcKey/EcKeyOption.php create mode 100644 src/AcmePhp/Ssl/Generator/KeyOption.php create mode 100644 src/AcmePhp/Ssl/Generator/KeyPairGenerator.php create mode 100644 src/AcmePhp/Ssl/Generator/OpensslPrivateKeyGeneratorTrait.php create mode 100644 src/AcmePhp/Ssl/Generator/PrivateKeyGeneratorInterface.php create mode 100644 src/AcmePhp/Ssl/Generator/RsaKey/RsaKeyGenerator.php create mode 100644 src/AcmePhp/Ssl/Generator/RsaKey/RsaKeyOption.php create mode 100644 src/AcmePhp/Ssl/Key.php create mode 100644 src/AcmePhp/Ssl/KeyPair.php create mode 100644 src/AcmePhp/Ssl/ParsedCertificate.php create mode 100644 src/AcmePhp/Ssl/ParsedKey.php create mode 100644 src/AcmePhp/Ssl/Parser/CertificateParser.php create mode 100644 src/AcmePhp/Ssl/Parser/KeyParser.php create mode 100644 src/AcmePhp/Ssl/PrivateKey.php create mode 100644 src/AcmePhp/Ssl/PublicKey.php create mode 100644 src/AcmePhp/Ssl/Signer/CertificateRequestSigner.php create mode 100644 src/AcmePhp/Ssl/Signer/DataSigner.php diff --git a/composer.json b/composer.json index 9a4ffa2..b741481 100644 --- a/composer.json +++ b/composer.json @@ -20,15 +20,15 @@ "require": { "php": "^7.2|^8.0", "ext-openssl": "*", - "acmephp/core": "^1.2|dev-master", - "illuminate/console": "^5.6.8|^6.0|^7.0|^8.0|^9.0", - "illuminate/filesystem": "^5.6.8|^6.0|^7.0|^8.0|^9.0", - "illuminate/support": "^5.6.8|^6.0|^7.0|^8.0|^9.0" + "guzzlehttp/guzzle": "^7.4", + "illuminate/console": "^7.0|^8.0|^9.0", + "illuminate/filesystem": "^7.0|^8.0|^9.0", + "illuminate/support": "^7.0|^8.0|^9.0" }, "require-dev": { "friendsofphp/php-cs-fixer": "^3.0", - "orchestra/testbench": "^5.0", - "phpunit/phpunit": "^8.0" + "orchestra/testbench": "^7.0", + "phpunit/phpunit": "^9.0" }, "autoload": { "psr-4": { diff --git a/src/AcmePhp/Core/AcmeClient.php b/src/AcmePhp/Core/AcmeClient.php new file mode 100644 index 0000000..e336031 --- /dev/null +++ b/src/AcmePhp/Core/AcmeClient.php @@ -0,0 +1,374 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Daanra\LaravelLetsEncrypt\AcmePhp\Core; + +use Daanra\LaravelLetsEncrypt\AcmePhp\Core\Exception\AcmeCoreClientException; +use Daanra\LaravelLetsEncrypt\AcmePhp\Core\Exception\AcmeCoreServerException; +use Daanra\LaravelLetsEncrypt\AcmePhp\Core\Exception\Protocol\CertificateRequestFailedException; +use Daanra\LaravelLetsEncrypt\AcmePhp\Core\Exception\Protocol\CertificateRevocationException; +use Daanra\LaravelLetsEncrypt\AcmePhp\Core\Exception\Protocol\ChallengeFailedException; +use Daanra\LaravelLetsEncrypt\AcmePhp\Core\Exception\Protocol\ChallengeNotSupportedException; +use Daanra\LaravelLetsEncrypt\AcmePhp\Core\Exception\Protocol\ChallengeTimedOutException; +use Daanra\LaravelLetsEncrypt\AcmePhp\Core\Http\SecureHttpClient; +use Daanra\LaravelLetsEncrypt\AcmePhp\Core\Protocol\AuthorizationChallenge; +use Daanra\LaravelLetsEncrypt\AcmePhp\Core\Protocol\CertificateOrder; +use Daanra\LaravelLetsEncrypt\AcmePhp\Core\Protocol\ResourcesDirectory; +use Daanra\LaravelLetsEncrypt\AcmePhp\Core\Protocol\RevocationReason; +use Daanra\LaravelLetsEncrypt\AcmePhp\Ssl\Certificate; +use Daanra\LaravelLetsEncrypt\AcmePhp\Ssl\CertificateRequest; +use Daanra\LaravelLetsEncrypt\AcmePhp\Ssl\CertificateResponse; +use Daanra\LaravelLetsEncrypt\AcmePhp\Ssl\Signer\CertificateRequestSigner; +use Webmozart\Assert\Assert; + +/** + * ACME protocol client implementation. + * + * @author Titouan Galopin + */ +class AcmeClient implements AcmeClientV2Interface +{ + /** + * @var SecureHttpClient + */ + private $uninitializedHttpClient; + + /** + * @var SecureHttpClient + */ + private $initializedHttpClient; + + /** + * @var CertificateRequestSigner + */ + private $csrSigner; + + /** + * @var string + */ + private $directoryUrl; + + /** + * @var ResourcesDirectory + */ + private $directory; + + /** + * @var string + */ + private $account; + + /** + * @param string $directoryUrl + */ + public function __construct(SecureHttpClient $httpClient, $directoryUrl, CertificateRequestSigner $csrSigner = null) + { + $this->uninitializedHttpClient = $httpClient; + $this->directoryUrl = $directoryUrl; + $this->csrSigner = $csrSigner ?: new CertificateRequestSigner(); + } + + /** + * {@inheritdoc} + */ + public function getHttpClient() + { + if (!$this->initializedHttpClient) { + $this->initializedHttpClient = $this->uninitializedHttpClient; + + $this->initializedHttpClient->setNonceEndpoint($this->getResourceUrl(ResourcesDirectory::NEW_NONCE)); + } + + return $this->initializedHttpClient; + } + + /** + * {@inheritdoc} + */ + public function registerAccount($agreement = null, $email = null) + { + Assert::nullOrString($agreement, 'registerAccount::$agreement expected a string or null. Got: %s'); + Assert::nullOrString($email, 'registerAccount::$email expected a string or null. Got: %s'); + + $payload = [ + 'termsOfServiceAgreed' => true, + 'contact' => [], + ]; + + if (\is_string($email)) { + $payload['contact'][] = 'mailto:'.$email; + } + + $this->requestResource('POST', ResourcesDirectory::NEW_ACCOUNT, $payload); + $account = $this->getResourceAccount(); + $client = $this->getHttpClient(); + + return $client->request('POST', $account, $client->signKidPayload($account, $account, null)); + } + + /** + * {@inheritdoc} + */ + public function requestAuthorization($domain) + { + $order = $this->requestOrder([$domain]); + + try { + return $order->getAuthorizationChallenges($domain); + } catch (AcmeCoreClientException $e) { + throw new ChallengeNotSupportedException(); + } + } + + /** + * {@inheritdoc} + */ + public function requestOrder(array $domains) + { + Assert::allStringNotEmpty($domains, 'requestOrder::$domains expected a list of strings. Got: %s'); + + $payload = [ + 'identifiers' => array_map( + 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(); + } + + $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); + } + + /** + * {@inheritdoc} + */ + public function reloadAuthorization(AuthorizationChallenge $challenge) + { + $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, $timeout = 180) + { + Assert::integer($timeout, 'challengeAuthorization::$timeout expected an integer. Got: %s'); + + $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($domain, CertificateRequest $csr, $timeout = 180) + { + Assert::stringNotEmpty($domain, 'requestCertificate::$domain expected a non-empty string. Got: %s'); + Assert::integer($timeout, 'requestCertificate::$timeout expected an integer. Got: %s'); + + $order = $this->requestOrder(array_unique(array_merge([$domain], $csr->getDistinguishedName()->getSubjectAlternativeNames()))); + + return $this->finalizeOrder($order, $csr, $timeout); + } + + /** + * {@inheritdoc} + */ + public function finalizeOrder(CertificateOrder $order, CertificateRequest $csr, $timeout = 180) + { + Assert::integer($timeout, 'finalizeOrder::$timeout expected an integer. Got: %s'); + + $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->request('POST', $response['certificate'], $client->signKidPayload($response['certificate'], $this->getResourceAccount(), null), false); + $certificatesChain = null; + foreach (array_reverse(explode("\n\n", $response)) as $pem) { + $certificatesChain = new Certificate($pem, $certificatesChain); + } + + return new CertificateResponse($csr, $certificatesChain); + } + + /** + * {@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. + * + * @param string $resource + * + * @return string + */ + public function getResourceUrl($resource) + { + 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). + * + * @param string $method + * @param string $resource + * @param bool $returnJson + * + * @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($method, $resource, array $payload, $returnJson = true) + { + $client = $this->getHttpClient(); + $endpoint = $this->getResourceUrl($resource); + + return $client->request( + $method, + $endpoint, + $client->signJwkPayload($endpoint, $payload), + $returnJson + ); + } + + /** + * Retrieve the resource account. + * + * @return string + */ + private function getResourceAccount() + { + 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) + { + $base64encoder = $this->getHttpClient()->getBase64Encoder(); + + return new AuthorizationChallenge( + $domain, + $response['status'], + $response['type'], + $response['url'], + $response['token'], + $response['token'].'.'.$base64encoder->encode($this->getHttpClient()->getJWKThumbprint()) + ); + } +} diff --git a/src/AcmePhp/Core/AcmeClientInterface.php b/src/AcmePhp/Core/AcmeClientInterface.php new file mode 100644 index 0000000..49b7d6a --- /dev/null +++ b/src/AcmePhp/Core/AcmeClientInterface.php @@ -0,0 +1,127 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Daanra\LaravelLetsEncrypt\AcmePhp\Core; + +use Daanra\LaravelLetsEncrypt\AcmePhp\Core\Exception\AcmeCoreClientException; +use Daanra\LaravelLetsEncrypt\AcmePhp\Core\Exception\AcmeCoreServerException; +use Daanra\LaravelLetsEncrypt\AcmePhp\Core\Exception\Protocol\CertificateRequestFailedException; +use Daanra\LaravelLetsEncrypt\AcmePhp\Core\Exception\Protocol\CertificateRequestTimedOutException; +use Daanra\LaravelLetsEncrypt\AcmePhp\Core\Exception\Protocol\CertificateRevocationException; +use Daanra\LaravelLetsEncrypt\AcmePhp\Core\Exception\Protocol\ChallengeFailedException; +use Daanra\LaravelLetsEncrypt\AcmePhp\Core\Exception\Protocol\ChallengeNotSupportedException; +use Daanra\LaravelLetsEncrypt\AcmePhp\Core\Exception\Protocol\ChallengeTimedOutException; +use Daanra\LaravelLetsEncrypt\AcmePhp\Core\Http\SecureHttpClient; +use Daanra\LaravelLetsEncrypt\AcmePhp\Core\Protocol\AuthorizationChallenge; +use Daanra\LaravelLetsEncrypt\AcmePhp\Core\Protocol\RevocationReason; +use Daanra\LaravelLetsEncrypt\AcmePhp\Ssl\Certificate; +use Daanra\LaravelLetsEncrypt\AcmePhp\Ssl\CertificateRequest; +use Daanra\LaravelLetsEncrypt\AcmePhp\Ssl\CertificateResponse; + +/** + * ACME protocol client interface. + * + * @author Titouan Galopin + */ +interface AcmeClientInterface +{ + /** + * Register the local account KeyPair in the Certificate Authority. + * + * @param string|null $agreement an optionnal URI referring to a subscriber agreement or terms of service + * @param string|null $email an optionnal e-mail to associate with the account + * + * @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($agreement = null, $email = null); + + /** + * 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($domain); + + /** + * 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, $timeout = 180); + + /** + * 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 + * + * @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($domain, CertificateRequest $csr, $timeout = 180); + + /** + * @throws CertificateRevocationException + */ + public function revokeCertificate(Certificate $certificate, RevocationReason $revocationReason = null); + + /** + * Get the HTTP client. + * + * @return SecureHttpClient + */ + public function getHttpClient(); +} diff --git a/src/AcmePhp/Core/AcmeClientV2Interface.php b/src/AcmePhp/Core/AcmeClientV2Interface.php new file mode 100644 index 0000000..133fa89 --- /dev/null +++ b/src/AcmePhp/Core/AcmeClientV2Interface.php @@ -0,0 +1,81 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Daanra\LaravelLetsEncrypt\AcmePhp\Core; + +use Daanra\LaravelLetsEncrypt\AcmePhp\Core\Exception\AcmeCoreClientException; +use Daanra\LaravelLetsEncrypt\AcmePhp\Core\Exception\AcmeCoreServerException; +use Daanra\LaravelLetsEncrypt\AcmePhp\Core\Exception\Protocol\CertificateRequestFailedException; +use Daanra\LaravelLetsEncrypt\AcmePhp\Core\Exception\Protocol\CertificateRequestTimedOutException; +use Daanra\LaravelLetsEncrypt\AcmePhp\Core\Exception\Protocol\ChallengeNotSupportedException; +use Daanra\LaravelLetsEncrypt\AcmePhp\Core\Protocol\AuthorizationChallenge; +use Daanra\LaravelLetsEncrypt\AcmePhp\Core\Protocol\CertificateOrder; +use Daanra\LaravelLetsEncrypt\AcmePhp\Ssl\CertificateRequest; +use Daanra\LaravelLetsEncrypt\AcmePhp\Ssl\CertificateResponse; + +/** + * ACME protocol client interface. + * + * @author Titouan Galopin + */ +interface AcmeClientV2Interface extends AcmeClientInterface +{ + /** + * 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); + + /** + * 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 + * + * @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, $timeout = 180); + + /** + * 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); +} diff --git a/src/AcmePhp/Core/Exception/AcmeCoreClientException.php b/src/AcmePhp/Core/Exception/AcmeCoreClientException.php new file mode 100644 index 0000000..d541fdd --- /dev/null +++ b/src/AcmePhp/Core/Exception/AcmeCoreClientException.php @@ -0,0 +1,25 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Daanra\LaravelLetsEncrypt\AcmePhp\Core\Exception; + +/** + * Error reported by the client. + * + * @author Titouan Galopin + */ +class AcmeCoreClientException extends AcmeCoreException +{ + public function __construct($message, \Exception $previous = null) + { + parent::__construct($message, 0, $previous); + } +} diff --git a/src/AcmePhp/Core/Exception/AcmeCoreException.php b/src/AcmePhp/Core/Exception/AcmeCoreException.php new file mode 100644 index 0000000..d16c2dc --- /dev/null +++ b/src/AcmePhp/Core/Exception/AcmeCoreException.php @@ -0,0 +1,19 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Daanra\LaravelLetsEncrypt\AcmePhp\Core\Exception; + +/** + * @author Titouan Galopin + */ +class AcmeCoreException extends \RuntimeException +{ +} diff --git a/src/AcmePhp/Core/Exception/AcmeCoreServerException.php b/src/AcmePhp/Core/Exception/AcmeCoreServerException.php new file mode 100644 index 0000000..47afafe --- /dev/null +++ b/src/AcmePhp/Core/Exception/AcmeCoreServerException.php @@ -0,0 +1,27 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Daanra\LaravelLetsEncrypt\AcmePhp\Core\Exception; + +use Psr\Http\Message\RequestInterface; + +/** + * Error reported by the server. + * + * @author Titouan Galopin + */ +class AcmeCoreServerException extends AcmeCoreException +{ + public function __construct(RequestInterface $request, $message, \Exception $previous = null) + { + parent::__construct($message, $previous ? $previous->getCode() : 0, $previous); + } +} diff --git a/src/AcmePhp/Core/Exception/AcmeDnsResolutionException.php b/src/AcmePhp/Core/Exception/AcmeDnsResolutionException.php new file mode 100644 index 0000000..66fb767 --- /dev/null +++ b/src/AcmePhp/Core/Exception/AcmeDnsResolutionException.php @@ -0,0 +1,23 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Daanra\LaravelLetsEncrypt\AcmePhp\Core\Exception; + +/** + * @author Jérémy Derussé + */ +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); + } +} diff --git a/src/AcmePhp/Core/Exception/Protocol/CertificateRequestFailedException.php b/src/AcmePhp/Core/Exception/Protocol/CertificateRequestFailedException.php new file mode 100644 index 0000000..1f29779 --- /dev/null +++ b/src/AcmePhp/Core/Exception/Protocol/CertificateRequestFailedException.php @@ -0,0 +1,23 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Daanra\LaravelLetsEncrypt\AcmePhp\Core\Exception\Protocol; + +/** + * @author Titouan Galopin + */ +class CertificateRequestFailedException extends ProtocolException +{ + public function __construct($response) + { + parent::__construct(sprintf('Certificate request failed (response: %s)', $response)); + } +} diff --git a/src/AcmePhp/Core/Exception/Protocol/CertificateRequestTimedOutException.php b/src/AcmePhp/Core/Exception/Protocol/CertificateRequestTimedOutException.php new file mode 100644 index 0000000..0db0c52 --- /dev/null +++ b/src/AcmePhp/Core/Exception/Protocol/CertificateRequestTimedOutException.php @@ -0,0 +1,23 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Daanra\LaravelLetsEncrypt\AcmePhp\Core\Exception\Protocol; + +/** + * @author Titouan Galopin + */ +class CertificateRequestTimedOutException extends ProtocolException +{ + public function __construct($response) + { + parent::__construct(sprintf('Certificate request timed out (response: %s)', $response)); + } +} diff --git a/src/AcmePhp/Core/Exception/Protocol/CertificateRevocationException.php b/src/AcmePhp/Core/Exception/Protocol/CertificateRevocationException.php new file mode 100644 index 0000000..b463981 --- /dev/null +++ b/src/AcmePhp/Core/Exception/Protocol/CertificateRevocationException.php @@ -0,0 +1,18 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Daanra\LaravelLetsEncrypt\AcmePhp\Core\Exception\Protocol; + +use Daanra\LaravelLetsEncrypt\AcmePhp\Core\Exception\AcmeCoreClientException; + +class CertificateRevocationException extends AcmeCoreClientException +{ +} diff --git a/src/AcmePhp/Core/Exception/Protocol/ChallengeFailedException.php b/src/AcmePhp/Core/Exception/Protocol/ChallengeFailedException.php new file mode 100644 index 0000000..7eb5a6f --- /dev/null +++ b/src/AcmePhp/Core/Exception/Protocol/ChallengeFailedException.php @@ -0,0 +1,38 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Daanra\LaravelLetsEncrypt\AcmePhp\Core\Exception\Protocol; + +/** + * @author Titouan Galopin + */ +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; + } +} diff --git a/src/AcmePhp/Core/Exception/Protocol/ChallengeNotSupportedException.php b/src/AcmePhp/Core/Exception/Protocol/ChallengeNotSupportedException.php new file mode 100644 index 0000000..5c68a26 --- /dev/null +++ b/src/AcmePhp/Core/Exception/Protocol/ChallengeNotSupportedException.php @@ -0,0 +1,23 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Daanra\LaravelLetsEncrypt\AcmePhp\Core\Exception\Protocol; + +/** + * @author Titouan Galopin + */ +class ChallengeNotSupportedException extends ProtocolException +{ + public function __construct(\Exception $previous = null) + { + parent::__construct('This ACME server does not expose supported challenge.', $previous); + } +} diff --git a/src/AcmePhp/Core/Exception/Protocol/ChallengeTimedOutException.php b/src/AcmePhp/Core/Exception/Protocol/ChallengeTimedOutException.php new file mode 100644 index 0000000..9d98d98 --- /dev/null +++ b/src/AcmePhp/Core/Exception/Protocol/ChallengeTimedOutException.php @@ -0,0 +1,38 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Daanra\LaravelLetsEncrypt\AcmePhp\Core\Exception\Protocol; + +/** + * @author Titouan Galopin + */ +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; + } +} diff --git a/src/AcmePhp/Core/Exception/Protocol/ExpectedJsonException.php b/src/AcmePhp/Core/Exception/Protocol/ExpectedJsonException.php new file mode 100644 index 0000000..509dc5e --- /dev/null +++ b/src/AcmePhp/Core/Exception/Protocol/ExpectedJsonException.php @@ -0,0 +1,19 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Daanra\LaravelLetsEncrypt\AcmePhp\Core\Exception\Protocol; + +/** + * @author Titouan Galopin + */ +class ExpectedJsonException extends ProtocolException +{ +} diff --git a/src/AcmePhp/Core/Exception/Protocol/ProtocolException.php b/src/AcmePhp/Core/Exception/Protocol/ProtocolException.php new file mode 100644 index 0000000..74fd182 --- /dev/null +++ b/src/AcmePhp/Core/Exception/Protocol/ProtocolException.php @@ -0,0 +1,23 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Daanra\LaravelLetsEncrypt\AcmePhp\Core\Exception\Protocol; + +use Daanra\LaravelLetsEncrypt\AcmePhp\Core\Exception\AcmeCoreClientException; + +/** + * Error because the protocol was not respected. + * + * @author Titouan Galopin + */ +class ProtocolException extends AcmeCoreClientException +{ +} diff --git a/src/AcmePhp/Core/Exception/Server/BadCsrServerException.php b/src/AcmePhp/Core/Exception/Server/BadCsrServerException.php new file mode 100644 index 0000000..afadc02 --- /dev/null +++ b/src/AcmePhp/Core/Exception/Server/BadCsrServerException.php @@ -0,0 +1,30 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Daanra\LaravelLetsEncrypt\AcmePhp\Core\Exception\Server; + +use Daanra\LaravelLetsEncrypt\AcmePhp\Core\Exception\AcmeCoreServerException; +use Psr\Http\Message\RequestInterface; + +/** + * @author Titouan Galopin + */ +class BadCsrServerException extends AcmeCoreServerException +{ + public function __construct(RequestInterface $request, $detail, \Exception $previous = null) + { + parent::__construct( + $request, + '[badCSR] The CSR is unacceptable (e.g., due to a short key): '.$detail, + $previous + ); + } +} diff --git a/src/AcmePhp/Core/Exception/Server/BadNonceServerException.php b/src/AcmePhp/Core/Exception/Server/BadNonceServerException.php new file mode 100644 index 0000000..2c7a1fd --- /dev/null +++ b/src/AcmePhp/Core/Exception/Server/BadNonceServerException.php @@ -0,0 +1,30 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Daanra\LaravelLetsEncrypt\AcmePhp\Core\Exception\Server; + +use Daanra\LaravelLetsEncrypt\AcmePhp\Core\Exception\AcmeCoreServerException; +use Psr\Http\Message\RequestInterface; + +/** + * @author Titouan Galopin + */ +class BadNonceServerException extends AcmeCoreServerException +{ + public function __construct(RequestInterface $request, $detail, \Exception $previous = null) + { + parent::__construct( + $request, + '[badNonce] The client sent an unacceptable anti-replay nonce: '.$detail, + $previous + ); + } +} diff --git a/src/AcmePhp/Core/Exception/Server/CaaServerException.php b/src/AcmePhp/Core/Exception/Server/CaaServerException.php new file mode 100644 index 0000000..46b9299 --- /dev/null +++ b/src/AcmePhp/Core/Exception/Server/CaaServerException.php @@ -0,0 +1,30 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Daanra\LaravelLetsEncrypt\AcmePhp\Core\Exception\Server; + +use Daanra\LaravelLetsEncrypt\AcmePhp\Core\Exception\AcmeCoreServerException; +use Psr\Http\Message\RequestInterface; + +/** + * @author Alex Plekhanov + */ +class CaaServerException extends AcmeCoreServerException +{ + public function __construct(RequestInterface $request, $detail, \Exception $previous = null) + { + parent::__construct( + $request, + '[caa] Certification Authority Authorization (CAA) records forbid the CA from issuing a certificate: '.$detail, + $previous + ); + } +} diff --git a/src/AcmePhp/Core/Exception/Server/ConnectionServerException.php b/src/AcmePhp/Core/Exception/Server/ConnectionServerException.php new file mode 100644 index 0000000..081a289 --- /dev/null +++ b/src/AcmePhp/Core/Exception/Server/ConnectionServerException.php @@ -0,0 +1,30 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Daanra\LaravelLetsEncrypt\AcmePhp\Core\Exception\Server; + +use Daanra\LaravelLetsEncrypt\AcmePhp\Core\Exception\AcmeCoreServerException; +use Psr\Http\Message\RequestInterface; + +/** + * @author Titouan Galopin + */ +class ConnectionServerException extends AcmeCoreServerException +{ + public function __construct(RequestInterface $request, $detail, \Exception $previous = null) + { + parent::__construct( + $request, + '[connection] The server could not connect to the client for DV: '.$detail, + $previous + ); + } +} diff --git a/src/AcmePhp/Core/Exception/Server/DnsServerException.php b/src/AcmePhp/Core/Exception/Server/DnsServerException.php new file mode 100644 index 0000000..12e89bf --- /dev/null +++ b/src/AcmePhp/Core/Exception/Server/DnsServerException.php @@ -0,0 +1,30 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Daanra\LaravelLetsEncrypt\AcmePhp\Core\Exception\Server; + +use Daanra\LaravelLetsEncrypt\AcmePhp\Core\Exception\AcmeCoreServerException; +use Psr\Http\Message\RequestInterface; + +/** + * @author Alex Plekhanov + */ +class DnsServerException extends AcmeCoreServerException +{ + public function __construct(RequestInterface $request, $detail, \Exception $previous = null) + { + parent::__construct( + $request, + '[dns] There was a problem with a DNS query during identifier validation: '.$detail, + $previous + ); + } +} diff --git a/src/AcmePhp/Core/Exception/Server/IncorrectResponseServerException.php b/src/AcmePhp/Core/Exception/Server/IncorrectResponseServerException.php new file mode 100644 index 0000000..9af8de6 --- /dev/null +++ b/src/AcmePhp/Core/Exception/Server/IncorrectResponseServerException.php @@ -0,0 +1,30 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Daanra\LaravelLetsEncrypt\AcmePhp\Core\Exception\Server; + +use Daanra\LaravelLetsEncrypt\AcmePhp\Core\Exception\AcmeCoreServerException; +use Psr\Http\Message\RequestInterface; + +/** + * @author Alex Plekhanov + */ +class IncorrectResponseServerException extends AcmeCoreServerException +{ + public function __construct(RequestInterface $request, $detail, \Exception $previous = null) + { + parent::__construct( + $request, + "[incorrectResponse] Response received didn’t match the challenge's requirements: ".$detail, + $previous + ); + } +} diff --git a/src/AcmePhp/Core/Exception/Server/InternalServerException.php b/src/AcmePhp/Core/Exception/Server/InternalServerException.php new file mode 100644 index 0000000..d5d995d --- /dev/null +++ b/src/AcmePhp/Core/Exception/Server/InternalServerException.php @@ -0,0 +1,30 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Daanra\LaravelLetsEncrypt\AcmePhp\Core\Exception\Server; + +use Daanra\LaravelLetsEncrypt\AcmePhp\Core\Exception\AcmeCoreServerException; +use Psr\Http\Message\RequestInterface; + +/** + * @author Titouan Galopin + */ +class InternalServerException extends AcmeCoreServerException +{ + public function __construct(RequestInterface $request, $detail, \Exception $previous = null) + { + parent::__construct( + $request, + '[serverInternal] The server experienced an internal error: '.$detail, + $previous + ); + } +} diff --git a/src/AcmePhp/Core/Exception/Server/InvalidContactServerException.php b/src/AcmePhp/Core/Exception/Server/InvalidContactServerException.php new file mode 100644 index 0000000..3b9fc9b --- /dev/null +++ b/src/AcmePhp/Core/Exception/Server/InvalidContactServerException.php @@ -0,0 +1,30 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Daanra\LaravelLetsEncrypt\AcmePhp\Core\Exception\Server; + +use Daanra\LaravelLetsEncrypt\AcmePhp\Core\Exception\AcmeCoreServerException; +use Psr\Http\Message\RequestInterface; + +/** + * @author Alex Plekhanov + */ +class InvalidContactServerException extends AcmeCoreServerException +{ + public function __construct(RequestInterface $request, $detail, \Exception $previous = null) + { + parent::__construct( + $request, + '[invalidContact] A contact URL for an account was invalid: '.$detail, + $previous + ); + } +} diff --git a/src/AcmePhp/Core/Exception/Server/InvalidEmailServerException.php b/src/AcmePhp/Core/Exception/Server/InvalidEmailServerException.php new file mode 100644 index 0000000..65c64a5 --- /dev/null +++ b/src/AcmePhp/Core/Exception/Server/InvalidEmailServerException.php @@ -0,0 +1,30 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Daanra\LaravelLetsEncrypt\AcmePhp\Core\Exception\Server; + +use Daanra\LaravelLetsEncrypt\AcmePhp\Core\Exception\AcmeCoreServerException; +use Psr\Http\Message\RequestInterface; + +/** + * @author Titouan Galopin + */ +class InvalidEmailServerException extends AcmeCoreServerException +{ + public function __construct(RequestInterface $request, $detail, \Exception $previous = null) + { + parent::__construct( + $request, + '[invalidEmail] This email is unacceptable (e.g., it is invalid): '.$detail, + $previous + ); + } +} diff --git a/src/AcmePhp/Core/Exception/Server/MalformedServerException.php b/src/AcmePhp/Core/Exception/Server/MalformedServerException.php new file mode 100644 index 0000000..71f1436 --- /dev/null +++ b/src/AcmePhp/Core/Exception/Server/MalformedServerException.php @@ -0,0 +1,30 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Daanra\LaravelLetsEncrypt\AcmePhp\Core\Exception\Server; + +use Daanra\LaravelLetsEncrypt\AcmePhp\Core\Exception\AcmeCoreServerException; +use Psr\Http\Message\RequestInterface; + +/** + * @author Titouan Galopin + */ +class MalformedServerException extends AcmeCoreServerException +{ + public function __construct(RequestInterface $request, $detail, \Exception $previous = null) + { + parent::__construct( + $request, + '[malformed] The request message was malformed: '.$detail, + $previous + ); + } +} diff --git a/src/AcmePhp/Core/Exception/Server/OrderNotReadyServerException.php b/src/AcmePhp/Core/Exception/Server/OrderNotReadyServerException.php new file mode 100644 index 0000000..2a85fcb --- /dev/null +++ b/src/AcmePhp/Core/Exception/Server/OrderNotReadyServerException.php @@ -0,0 +1,27 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Daanra\LaravelLetsEncrypt\AcmePhp\Core\Exception\Server; + +use Daanra\LaravelLetsEncrypt\AcmePhp\Core\Exception\AcmeCoreServerException; +use Psr\Http\Message\RequestInterface; + +class OrderNotReadyServerException extends AcmeCoreServerException +{ + public function __construct(RequestInterface $request, $detail, \Exception $previous = null) + { + parent::__construct( + $request, + '[orderNotReady] Order could not be finalized: '.$detail, + $previous + ); + } +} diff --git a/src/AcmePhp/Core/Exception/Server/RateLimitedServerException.php b/src/AcmePhp/Core/Exception/Server/RateLimitedServerException.php new file mode 100644 index 0000000..86c5d99 --- /dev/null +++ b/src/AcmePhp/Core/Exception/Server/RateLimitedServerException.php @@ -0,0 +1,30 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Daanra\LaravelLetsEncrypt\AcmePhp\Core\Exception\Server; + +use Daanra\LaravelLetsEncrypt\AcmePhp\Core\Exception\AcmeCoreServerException; +use Psr\Http\Message\RequestInterface; + +/** + * @author Titouan Galopin + */ +class RateLimitedServerException extends AcmeCoreServerException +{ + public function __construct(RequestInterface $request, $detail, \Exception $previous = null) + { + parent::__construct( + $request, + '[rateLimited] This client reached the rate limit of the server: '.$detail, + $previous + ); + } +} diff --git a/src/AcmePhp/Core/Exception/Server/RejectedIdentifierServerException.php b/src/AcmePhp/Core/Exception/Server/RejectedIdentifierServerException.php new file mode 100644 index 0000000..89d0940 --- /dev/null +++ b/src/AcmePhp/Core/Exception/Server/RejectedIdentifierServerException.php @@ -0,0 +1,30 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Daanra\LaravelLetsEncrypt\AcmePhp\Core\Exception\Server; + +use Daanra\LaravelLetsEncrypt\AcmePhp\Core\Exception\AcmeCoreServerException; +use Psr\Http\Message\RequestInterface; + +/** + * @author Alex Plekhanov + */ +class RejectedIdentifierServerException extends AcmeCoreServerException +{ + public function __construct(RequestInterface $request, $detail, \Exception $previous = null) + { + parent::__construct( + $request, + '[rejectedIdentifier] The server will not issue certificates for the identifier: '.$detail, + $previous + ); + } +} diff --git a/src/AcmePhp/Core/Exception/Server/TlsServerException.php b/src/AcmePhp/Core/Exception/Server/TlsServerException.php new file mode 100644 index 0000000..719cf1f --- /dev/null +++ b/src/AcmePhp/Core/Exception/Server/TlsServerException.php @@ -0,0 +1,30 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Daanra\LaravelLetsEncrypt\AcmePhp\Core\Exception\Server; + +use Daanra\LaravelLetsEncrypt\AcmePhp\Core\Exception\AcmeCoreServerException; +use Psr\Http\Message\RequestInterface; + +/** + * @author Titouan Galopin + */ +class TlsServerException extends AcmeCoreServerException +{ + public function __construct(RequestInterface $request, $detail, \Exception $previous = null) + { + parent::__construct( + $request, + '[tls] The server experienced a TLS error during DV: '.$detail, + $previous + ); + } +} diff --git a/src/AcmePhp/Core/Exception/Server/UnauthorizedServerException.php b/src/AcmePhp/Core/Exception/Server/UnauthorizedServerException.php new file mode 100644 index 0000000..d5b95d1 --- /dev/null +++ b/src/AcmePhp/Core/Exception/Server/UnauthorizedServerException.php @@ -0,0 +1,30 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Daanra\LaravelLetsEncrypt\AcmePhp\Core\Exception\Server; + +use Daanra\LaravelLetsEncrypt\AcmePhp\Core\Exception\AcmeCoreServerException; +use Psr\Http\Message\RequestInterface; + +/** + * @author Titouan Galopin + */ +class UnauthorizedServerException extends AcmeCoreServerException +{ + public function __construct(RequestInterface $request, $detail, \Exception $previous = null) + { + parent::__construct( + $request, + '[unauthorized] The client lacks sufficient authorization: '.$detail, + $previous + ); + } +} diff --git a/src/AcmePhp/Core/Exception/Server/UnknownHostServerException.php b/src/AcmePhp/Core/Exception/Server/UnknownHostServerException.php new file mode 100644 index 0000000..c8bbfab --- /dev/null +++ b/src/AcmePhp/Core/Exception/Server/UnknownHostServerException.php @@ -0,0 +1,30 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Daanra\LaravelLetsEncrypt\AcmePhp\Core\Exception\Server; + +use Daanra\LaravelLetsEncrypt\AcmePhp\Core\Exception\AcmeCoreServerException; +use Psr\Http\Message\RequestInterface; + +/** + * @author Titouan Galopin + */ +class UnknownHostServerException extends AcmeCoreServerException +{ + public function __construct(RequestInterface $request, $detail, \Exception $previous = null) + { + parent::__construct( + $request, + '[unknownHost] The server could not resolve a domain name: '.$detail, + $previous + ); + } +} diff --git a/src/AcmePhp/Core/Exception/Server/UnsupportedContactServerException.php b/src/AcmePhp/Core/Exception/Server/UnsupportedContactServerException.php new file mode 100644 index 0000000..ec196a4 --- /dev/null +++ b/src/AcmePhp/Core/Exception/Server/UnsupportedContactServerException.php @@ -0,0 +1,30 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Daanra\LaravelLetsEncrypt\AcmePhp\Core\Exception\Server; + +use Daanra\LaravelLetsEncrypt\AcmePhp\Core\Exception\AcmeCoreServerException; +use Psr\Http\Message\RequestInterface; + +/** + * @author Alex Plekhanov + */ +class UnsupportedContactServerException extends AcmeCoreServerException +{ + public function __construct(RequestInterface $request, $detail, \Exception $previous = null) + { + parent::__construct( + $request, + '[unsupportedContact] A contact URL for an account used an unsupported protocol scheme: '.$detail, + $previous + ); + } +} diff --git a/src/AcmePhp/Core/Exception/Server/UnsupportedIdentifierServerException.php b/src/AcmePhp/Core/Exception/Server/UnsupportedIdentifierServerException.php new file mode 100644 index 0000000..db27554 --- /dev/null +++ b/src/AcmePhp/Core/Exception/Server/UnsupportedIdentifierServerException.php @@ -0,0 +1,30 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Daanra\LaravelLetsEncrypt\AcmePhp\Core\Exception\Server; + +use Daanra\LaravelLetsEncrypt\AcmePhp\Core\Exception\AcmeCoreServerException; +use Psr\Http\Message\RequestInterface; + +/** + * @author Alex Plekhanov + */ +class UnsupportedIdentifierServerException extends AcmeCoreServerException +{ + public function __construct(RequestInterface $request, $detail, \Exception $previous = null) + { + parent::__construct( + $request, + '[unsupportedIdentifier] An identifier is of an unsupported type: '.$detail, + $previous + ); + } +} diff --git a/src/AcmePhp/Core/Exception/Server/UserActionRequiredServerException.php b/src/AcmePhp/Core/Exception/Server/UserActionRequiredServerException.php new file mode 100644 index 0000000..f09b4a8 --- /dev/null +++ b/src/AcmePhp/Core/Exception/Server/UserActionRequiredServerException.php @@ -0,0 +1,30 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Daanra\LaravelLetsEncrypt\AcmePhp\Core\Exception\Server; + +use Daanra\LaravelLetsEncrypt\AcmePhp\Core\Exception\AcmeCoreServerException; +use Psr\Http\Message\RequestInterface; + +/** + * @author Alex Plekhanov + */ +class UserActionRequiredServerException extends AcmeCoreServerException +{ + public function __construct(RequestInterface $request, $detail, \Exception $previous = null) + { + parent::__construct( + $request, + '[userActionRequired] Visit the “instance” URL and take actions specified there: '.$detail, + $previous + ); + } +} diff --git a/src/AcmePhp/Core/Http/Base64SafeEncoder.php b/src/AcmePhp/Core/Http/Base64SafeEncoder.php new file mode 100644 index 0000000..48d5c87 --- /dev/null +++ b/src/AcmePhp/Core/Http/Base64SafeEncoder.php @@ -0,0 +1,47 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Daanra\LaravelLetsEncrypt\AcmePhp\Core\Http; + +/** + * Encode and decode safely in base64. + * + * @author Titouan Galopin + */ +class Base64SafeEncoder +{ + /** + * @param string $input + * + * @return string + */ + public function encode($input) + { + return str_replace('=', '', strtr(base64_encode($input), '+/', '-_')); + } + + /** + * @param string $input + * + * @return string + */ + public function decode($input) + { + $remainder = \strlen($input) % 4; + + if ($remainder) { + $padlen = 4 - $remainder; + $input .= str_repeat('=', $padlen); + } + + return base64_decode(strtr($input, '-_', '+/')); + } +} diff --git a/src/AcmePhp/Core/Http/SecureHttpClient.php b/src/AcmePhp/Core/Http/SecureHttpClient.php new file mode 100644 index 0000000..c9ae8c0 --- /dev/null +++ b/src/AcmePhp/Core/Http/SecureHttpClient.php @@ -0,0 +1,455 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Daanra\LaravelLetsEncrypt\AcmePhp\Core\Http; + +use Daanra\LaravelLetsEncrypt\AcmePhp\Core\Exception\AcmeCoreClientException; +use Daanra\LaravelLetsEncrypt\AcmePhp\Core\Exception\AcmeCoreServerException; +use Daanra\LaravelLetsEncrypt\AcmePhp\Core\Exception\Protocol\ExpectedJsonException; +use Daanra\LaravelLetsEncrypt\AcmePhp\Core\Exception\Server\BadNonceServerException; +use Daanra\LaravelLetsEncrypt\AcmePhp\Core\Util\JsonDecoder; +use Daanra\LaravelLetsEncrypt\AcmePhp\Ssl\KeyPair; +use Daanra\LaravelLetsEncrypt\AcmePhp\Ssl\Parser\KeyParser; +use Daanra\LaravelLetsEncrypt\AcmePhp\Ssl\Signer\DataSigner; +use GuzzleHttp\ClientInterface; +use GuzzleHttp\Exception\RequestException; +use GuzzleHttp\Psr7\Request; +use GuzzleHttp\Psr7\Utils; +use Psr\Http\Message\ResponseInterface; + +/** + * Guzzle HTTP client wrapper to send requests signed with the account KeyPair. + * + * @author Titouan Galopin + */ +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; + } + + /** + * Send a request encoded in the format defined by the ACME protocol. + * + * @param string $method + * @param string $endpoint + * @param bool $returnJson + * + * @throws AcmeCoreServerException when the ACME server returns an error HTTP status code + * @throws AcmeCoreClientException when an error occured during response parsing + * + * @return array|string Array of parsed JSON if $returnJson = true, string otherwise + */ + public function signedRequest($method, $endpoint, array $payload = [], $returnJson = true) + { + @trigger_error('The method signedRequest is deprecated since version 1.1 and will be removed in 2.0. use methods request, signKidPayload instead', E_USER_DEPRECATED); + + return $this->request($method, $endpoint, $this->signJwkPayload($endpoint, $payload), $returnJson); + } + + private function getAlg() + { + $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) + { + 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]; + } + + public function getJWK() + { + $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() + { + return hash('sha256', json_encode($this->getJWK()), true); + } + + /** + * Generates a payload signed with account's KID. + * + * @param string $endpoint + * @param string $account + * @param array $payload + * + * @return array the signed Pyaload + */ + public function signKidPayload($endpoint, $account, array $payload = null) + { + return $this->signPayload( + [ + 'alg' => $this->getAlg(), + 'kid' => $account, + 'nonce' => $this->getNonce(), + 'url' => $endpoint, + ], + $payload + ); + } + + /** + * Generates a payload signed with JWK. + * + * @param string $endpoint + * @param array $payload + * + * @return array the signed Payload + */ + public function signJwkPayload($endpoint, array $payload = null) + { + return $this->signPayload( + [ + 'alg' => $this->getAlg(), + 'jwk' => $this->getJWK(), + 'nonce' => $this->getNonce(), + 'url' => $endpoint, + ], + $payload + ); + } + + /** + * Sign the given Payload. + * + * @return array + */ + private function signPayload(array $protected, array $payload = null) + { + 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); + + $protected = $this->base64Encoder->encode(json_encode($protected, JSON_UNESCAPED_SLASHES)); + if (null === $payload) { + $payload = ''; + } elseif ([] === $payload) { + $payload = $this->base64Encoder->encode('{}'); + } else { + $payload = $this->base64Encoder->encode(json_encode($payload, JSON_UNESCAPED_SLASHES)); + } + $signature = $this->base64Encoder->encode( + $this->dataSigner->signData($protected.'.'.$payload, $privateKey, $algorithm, $format) + ); + + return [ + 'protected' => $protected, + 'payload' => $payload, + 'signature' => $signature, + ]; + } + + /** + * Send a request encoded in the format defined by the ACME protocol. + * + * @param string $method + * @param string $endpoint + * @param string $account + * @param bool $returnJson + * + * @throws AcmeCoreClientException when an error occured during response parsing + * @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 signedKidRequest($method, $endpoint, $account, array $payload = [], $returnJson = true) + { + @trigger_error('The method signedKidRequest is deprecated since version 1.1 and will be removed in 2.0. use methods request, signKidPayload instead.', E_USER_DEPRECATED); + + return $this->request($method, $endpoint, $this->signKidPayload($endpoint, $account, $payload), $returnJson); + } + + /** + * Send a request encoded in the format defined by the ACME protocol. + * + * @param string $method + * @param string $endpoint + * @param array $data + * @param bool $returnJson + * + * @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 unsignedRequest($method, $endpoint, array $data = null, $returnJson = true) + { + @trigger_error('The method unsignedRequest is deprecated since version 1.1 and will be removed in 2.0. use methods request instead.', E_USER_DEPRECATED); + + return $this->request($method, $endpoint, $data, $returnJson); + } + + /** + * Send a request encoded in the format defined by the ACME protocol. + * + * @param string $method + * @param string $endpoint + * @param bool $returnJson + * + * @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($method, $endpoint, array $data = [], $returnJson = true) + { + $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 { + $request = $call(); + } catch (BadNonceServerException $e) { + $request = $call(); + } + + $body = Utils::copyToString($this->lastResponse->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")', $request->getMethod(), $request->getUri(), ServerErrorHandler::getResponseBodySummary($this->lastResponse)), $exception); + } + + return $data; + } + + public function setAccountKeyPair(KeyPair $keyPair) + { + $this->accountKeyPair = $keyPair; + } + + /** + * @return int + */ + public function getLastCode() + { + return $this->lastResponse->getStatusCode(); + } + + /** + * @return string + */ + public function getLastLocation() + { + return $this->lastResponse->getHeaderLine('Location'); + } + + /** + * @return KeyPair + */ + public function getAccountKeyPair() + { + return $this->accountKeyPair; + } + + /** + * @return KeyParser + */ + public function getKeyParser() + { + return $this->keyParser; + } + + /** + * @return DataSigner + */ + public function getDataSigner() + { + return $this->dataSigner; + } + + /** + * @param string $endpoint + */ + public function setNonceEndpoint($endpoint) + { + $this->nonceEndpoint = $endpoint; + } + + /** + * @return Base64SafeEncoder + */ + public function getBase64Encoder() + { + return $this->base64Encoder; + } + + 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(\GuzzleHttp\Psr7\stream_for(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() + { + 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'); + } + } + } +} diff --git a/src/AcmePhp/Core/Http/SecureHttpClientFactory.php b/src/AcmePhp/Core/Http/SecureHttpClientFactory.php new file mode 100644 index 0000000..c5c0b56 --- /dev/null +++ b/src/AcmePhp/Core/Http/SecureHttpClientFactory.php @@ -0,0 +1,81 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Daanra\LaravelLetsEncrypt\AcmePhp\Core\Http; + +use Daanra\LaravelLetsEncrypt\AcmePhp\Ssl\KeyPair; +use Daanra\LaravelLetsEncrypt\AcmePhp\Ssl\Parser\KeyParser; +use Daanra\LaravelLetsEncrypt\AcmePhp\Ssl\Signer\DataSigner; +use GuzzleHttp\ClientInterface; + +/** + * Guzzle HTTP client wrapper to send requests signed with the account KeyPair. + * + * @author Titouan Galopin + */ +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. + * + * @return SecureHttpClient + */ + public function createSecureHttpClient(KeyPair $accountKeyPair) + { + return new SecureHttpClient( + $accountKeyPair, + $this->httpClient, + $this->base64Encoder, + $this->keyParser, + $this->dataSigner, + $this->errorHandler + ); + } +} diff --git a/src/AcmePhp/Core/Http/ServerErrorHandler.php b/src/AcmePhp/Core/Http/ServerErrorHandler.php new file mode 100644 index 0000000..7094b13 --- /dev/null +++ b/src/AcmePhp/Core/Http/ServerErrorHandler.php @@ -0,0 +1,152 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Daanra\LaravelLetsEncrypt\AcmePhp\Core\Http; + +use Daanra\LaravelLetsEncrypt\AcmePhp\Core\Exception\AcmeCoreServerException; +use Daanra\LaravelLetsEncrypt\AcmePhp\Core\Exception\Server\BadCsrServerException; +use Daanra\LaravelLetsEncrypt\AcmePhp\Core\Exception\Server\BadNonceServerException; +use Daanra\LaravelLetsEncrypt\AcmePhp\Core\Exception\Server\CaaServerException; +use Daanra\LaravelLetsEncrypt\AcmePhp\Core\Exception\Server\ConnectionServerException; +use Daanra\LaravelLetsEncrypt\AcmePhp\Core\Exception\Server\DnsServerException; +use Daanra\LaravelLetsEncrypt\AcmePhp\Core\Exception\Server\IncorrectResponseServerException; +use Daanra\LaravelLetsEncrypt\AcmePhp\Core\Exception\Server\InternalServerException; +use Daanra\LaravelLetsEncrypt\AcmePhp\Core\Exception\Server\InvalidContactServerException; +use Daanra\LaravelLetsEncrypt\AcmePhp\Core\Exception\Server\InvalidEmailServerException; +use Daanra\LaravelLetsEncrypt\AcmePhp\Core\Exception\Server\MalformedServerException; +use Daanra\LaravelLetsEncrypt\AcmePhp\Core\Exception\Server\OrderNotReadyServerException; +use Daanra\LaravelLetsEncrypt\AcmePhp\Core\Exception\Server\RateLimitedServerException; +use Daanra\LaravelLetsEncrypt\AcmePhp\Core\Exception\Server\RejectedIdentifierServerException; +use Daanra\LaravelLetsEncrypt\AcmePhp\Core\Exception\Server\TlsServerException; +use Daanra\LaravelLetsEncrypt\AcmePhp\Core\Exception\Server\UnauthorizedServerException; +use Daanra\LaravelLetsEncrypt\AcmePhp\Core\Exception\Server\UnknownHostServerException; +use Daanra\LaravelLetsEncrypt\AcmePhp\Core\Exception\Server\UnsupportedContactServerException; +use Daanra\LaravelLetsEncrypt\AcmePhp\Core\Exception\Server\UnsupportedIdentifierServerException; +use Daanra\LaravelLetsEncrypt\AcmePhp\Core\Exception\Server\UserActionRequiredServerException; +use Daanra\LaravelLetsEncrypt\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 + */ +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+). + * + * @return string + */ + public static function getResponseBodySummary(ResponseInterface $response) + { + // 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; + } + + /** + * @return AcmeCoreServerException + */ + public function createAcmeExceptionForResponse( + RequestInterface $request, + ResponseInterface $response, + \Exception $previous = null + ) { + $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 + ); + } + + /** + * @return AcmeCoreServerException + */ + private function createDefaultExceptionForResponse( + RequestInterface $request, + ResponseInterface $response, + \Exception $previous = null + ) { + 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 + ); + } +} diff --git a/src/AcmePhp/Core/Protocol/AuthorizationChallenge.php b/src/AcmePhp/Core/Protocol/AuthorizationChallenge.php new file mode 100644 index 0000000..a6cefbe --- /dev/null +++ b/src/AcmePhp/Core/Protocol/AuthorizationChallenge.php @@ -0,0 +1,171 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Daanra\LaravelLetsEncrypt\AcmePhp\Core\Protocol; + +use Webmozart\Assert\Assert; + +/** + * Represent a ACME challenge. + * + * @author Titouan Galopin + */ +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; + + /** + * @param string $domain + * @param string $status + * @param string $type + * @param string $url + * @param string $token + * @param string $payload + */ + public function __construct($domain, $status, $type, $url, $token, $payload) + { + Assert::stringNotEmpty($domain, 'Challenge::$domain expected a non-empty string. Got: %s'); + Assert::stringNotEmpty($status, 'Challenge::$status expected a non-empty string. Got: %s'); + Assert::stringNotEmpty($type, 'Challenge::$type expected a non-empty string. Got: %s'); + Assert::stringNotEmpty($url, 'Challenge::$url expected a non-empty string. Got: %s'); + Assert::stringNotEmpty($token, 'Challenge::$token expected a non-empty string. Got: %s'); + Assert::stringNotEmpty($payload, 'Challenge::$payload expected a non-empty string. Got: %s'); + + $this->domain = $domain; + $this->status = $status; + $this->type = $type; + $this->url = $url; + $this->token = $token; + $this->payload = $payload; + } + + /** + * @return array + */ + public function toArray() + { + return [ + 'domain' => $this->getDomain(), + 'status' => $this->getStatus(), + 'type' => $this->getType(), + 'url' => $this->getUrl(), + 'token' => $this->getToken(), + 'payload' => $this->getPayload(), + ]; + } + + /** + * @return AuthorizationChallenge + */ + public static function fromArray(array $data) + { + return new self( + $data['domain'], + $data['status'], + $data['type'], + $data['url'], + $data['token'], + $data['payload'] + ); + } + + /** + * @return string + */ + public function getDomain() + { + return $this->domain; + } + + /** + * @return string + */ + public function getStatus() + { + return $this->status; + } + + /** + * @return bool + */ + public function isValid() + { + return 'valid' === $this->status; + } + + /** + * @return bool + */ + public function isPending() + { + return 'pending' === $this->status || 'processing' === $this->status; + } + + /** + * @return string + */ + public function getType() + { + return $this->type; + } + + /** + * @return string + */ + public function getUrl() + { + return $this->url; + } + + /** + * @return string + */ + public function getToken() + { + return $this->token; + } + + /** + * @return string + */ + public function getPayload() + { + return $this->payload; + } +} diff --git a/src/AcmePhp/Core/Protocol/CertificateOrder.php b/src/AcmePhp/Core/Protocol/CertificateOrder.php new file mode 100644 index 0000000..e94052b --- /dev/null +++ b/src/AcmePhp/Core/Protocol/CertificateOrder.php @@ -0,0 +1,110 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Daanra\LaravelLetsEncrypt\AcmePhp\Core\Protocol; + +use Daanra\LaravelLetsEncrypt\AcmePhp\Core\Exception\AcmeCoreClientException; +use Webmozart\Assert\Assert; + +/** + * Represent an ACME order. + * + * @author Jérémy Derussé + */ +class CertificateOrder +{ + /** + * @var AuthorizationChallenge[][] + */ + private $authorizationsChallenges; + + /** + * @var string + */ + private $orderEndpoint; + + /** + * @param string $domain + * @param string $type + * @param string $url + * @param string $token + * @param string $payload + * @param string $order + */ + public function __construct($authorizationsChallenges, $orderEndpoint = null) + { + Assert::isArray($authorizationsChallenges, 'Challenge::$authorizationsChallenges expected an array. Got: %s'); + Assert::nullOrString($orderEndpoint, 'Challenge::$orderEndpoint expected a string or null. Got: %s'); + + foreach ($authorizationsChallenges as &$authorizationChallenges) { + foreach ($authorizationChallenges as &$authorizationChallenge) { + if (\is_array($authorizationChallenge)) { + $authorizationChallenge = AuthorizationChallenge::fromArray($authorizationChallenge); + } + } + } + + $this->authorizationsChallenges = $authorizationsChallenges; + $this->orderEndpoint = $orderEndpoint; + } + + /** + * @return array + */ + public function toArray() + { + return [ + 'authorizationsChallenges' => $this->getAuthorizationsChallenges(), + 'orderEndpoint' => $this->getOrderEndpoint(), + ]; + } + + /** + * @return AuthorizationChallenge + */ + public static function fromArray(array $data) + { + return new self( + $data['authorizationsChallenges'], + $data['orderEndpoint'] + ); + } + + /** + * @return AuthorizationChallenge[][] + */ + public function getAuthorizationsChallenges() + { + return $this->authorizationsChallenges; + } + + /** + * @param string $domain + * + * @return AuthorizationChallenge[] + */ + public function getAuthorizationChallenges($domain) + { + if (!isset($this->authorizationsChallenges[$domain])) { + throw new AcmeCoreClientException('The order does not contains any authorization challenge for the domain '.$domain); + } + + return $this->authorizationsChallenges[$domain]; + } + + /** + * @return string + */ + public function getOrderEndpoint() + { + return $this->orderEndpoint; + } +} diff --git a/src/AcmePhp/Core/Protocol/ResourcesDirectory.php b/src/AcmePhp/Core/Protocol/ResourcesDirectory.php new file mode 100644 index 0000000..e76ad2b --- /dev/null +++ b/src/AcmePhp/Core/Protocol/ResourcesDirectory.php @@ -0,0 +1,68 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Daanra\LaravelLetsEncrypt\AcmePhp\Core\Protocol; + +use Webmozart\Assert\Assert; + +/** + * Represent a ACME resources directory. + * + * @author Titouan Galopin + */ +class ResourcesDirectory +{ + const NEW_ACCOUNT = 'newAccount'; + const NEW_ORDER = 'newOrder'; + const NEW_NONCE = 'newNonce'; + const REVOKE_CERT = 'revokeCert'; + + /** + * @var array + */ + private $serverResources; + + public function __construct(array $serverResources) + { + $this->serverResources = $serverResources; + } + + /** + * @return string[] + */ + public static function getResourcesNames() + { + return [ + self::NEW_ACCOUNT, + self::NEW_ORDER, + self::NEW_NONCE, + self::REVOKE_CERT, + ]; + } + + /** + * Find a resource URL. + * + * @param string $resource + * + * @return string + */ + public function getResourceUrl($resource) + { + Assert::oneOf( + $resource, + self::getResourcesNames(), + 'Resource type "%s" is not supported by the ACME server (supported: %2$s)' + ); + + return isset($this->serverResources[$resource]) ? $this->serverResources[$resource] : null; + } +} diff --git a/src/AcmePhp/Core/Protocol/RevocationReason.php b/src/AcmePhp/Core/Protocol/RevocationReason.php new file mode 100644 index 0000000..3335628 --- /dev/null +++ b/src/AcmePhp/Core/Protocol/RevocationReason.php @@ -0,0 +1,104 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Daanra\LaravelLetsEncrypt\AcmePhp\Core\Protocol; + +use Webmozart\Assert\Assert; + +/** + * @url https://github.com/certbot/certbot/blob/c326c021082dede7c3b2bd411cec3aec6dff0ac5/certbot/constants.py#L124 + */ +class RevocationReason +{ + const DEFAULT_REASON = self::REASON_UNSPECIFIED; + const REASON_UNSPECIFIED = 0; + const REASON_KEY_COMPROMISE = 1; + const REASON_AFFILLIATION_CHANGED = 3; + const REASON_SUPERCEDED = 4; + const REASON_CESSATION_OF_OPERATION = 5; + + /** + * @var int|null + */ + private $reasonType = null; + + /** + * @param int $reasonType + * + * @throws \InvalidArgumentException + */ + public function __construct($reasonType) + { + $reasonType = (int) $reasonType; + + Assert::oneOf($reasonType, self::getReasons(), 'Revocation reason type "%s" is not supported by the ACME server (supported: %2$s)'); + + $this->reasonType = $reasonType; + } + + /** + * @return int + */ + public function getReasonType() + { + return $this->reasonType; + } + + /** + * @return static + */ + public static function createDefaultReason() + { + return new static(self::DEFAULT_REASON); + } + + /** + * @return array + */ + public static function getFormattedReasons() + { + $formatted = []; + + foreach (self::getReasonLabelMap() as $reason => $label) { + $formatted[] = $reason.' - '.$label; + } + + return $formatted; + } + + /** + * @return array + */ + private static function getReasonLabelMap() + { + 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', + ]; + } + + /** + * @return array + */ + public static function getReasons() + { + return [ + self::REASON_UNSPECIFIED, + self::REASON_KEY_COMPROMISE, + self::REASON_AFFILLIATION_CHANGED, + self::REASON_SUPERCEDED, + self::REASON_CESSATION_OF_OPERATION, + ]; + } +} diff --git a/src/AcmePhp/Core/Util/JsonDecoder.php b/src/AcmePhp/Core/Util/JsonDecoder.php new file mode 100644 index 0000000..cb5df04 --- /dev/null +++ b/src/AcmePhp/Core/Util/JsonDecoder.php @@ -0,0 +1,49 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Daanra\LaravelLetsEncrypt\AcmePhp\Core\Util; + +/** + * Guzzle HTTP client wrapper to send requests signed with the account KeyPair. + * + * @author Titouan Galopin + * + * @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($json, $assoc = false, $depth = 512, $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; + } +} diff --git a/src/AcmePhp/Ssl/Certificate.php b/src/AcmePhp/Ssl/Certificate.php new file mode 100644 index 0000000..3ab3487 --- /dev/null +++ b/src/AcmePhp/Ssl/Certificate.php @@ -0,0 +1,93 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Daanra\LaravelLetsEncrypt\AcmePhp\Ssl; + +use Daanra\LaravelLetsEncrypt\AcmePhp\Ssl\Exception\CertificateFormatException; +use Webmozart\Assert\Assert; + +/** + * Represent a Certificate. + * + * @author Jérémy Derussé + */ +class Certificate +{ + /** @var string */ + private $certificatePEM; + + /** @var Certificate */ + private $issuerCertificate; + + /** + * @param string $certificatePEM + * @param Certificate|null $issuerCertificate + */ + public function __construct($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() + { + $chain = []; + $issuerCertificate = $this->getIssuerCertificate(); + + while (null !== $issuerCertificate) { + $chain[] = $issuerCertificate; + $issuerCertificate = $issuerCertificate->getIssuerCertificate(); + } + + return $chain; + } + + /** + * @return string + */ + public function getPEM() + { + return $this->certificatePEM; + } + + /** + * @return Certificate|null + */ + public function getIssuerCertificate() + { + 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; + } + + /** + * @return PublicKey + */ + public function getPublicKey() + { + return new PublicKey(openssl_pkey_get_details($this->getPublicKeyResource())['key']); + } +} diff --git a/src/AcmePhp/Ssl/CertificateRequest.php b/src/AcmePhp/Ssl/CertificateRequest.php new file mode 100644 index 0000000..c58db86 --- /dev/null +++ b/src/AcmePhp/Ssl/CertificateRequest.php @@ -0,0 +1,48 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Daanra\LaravelLetsEncrypt\AcmePhp\Ssl; + +/** + * Contains data required to request a certificate. + * + * @author Jérémy Derussé + */ +class CertificateRequest +{ + /** @var DistinguishedName */ + private $distinguishedName; + + /** @var KeyPair */ + private $keyPair; + + public function __construct(DistinguishedName $distinguishedName, KeyPair $keyPair) + { + $this->distinguishedName = $distinguishedName; + $this->keyPair = $keyPair; + } + + /** + * @return DistinguishedName + */ + public function getDistinguishedName() + { + return $this->distinguishedName; + } + + /** + * @return KeyPair + */ + public function getKeyPair() + { + return $this->keyPair; + } +} diff --git a/src/AcmePhp/Ssl/CertificateResponse.php b/src/AcmePhp/Ssl/CertificateResponse.php new file mode 100644 index 0000000..9ba80a6 --- /dev/null +++ b/src/AcmePhp/Ssl/CertificateResponse.php @@ -0,0 +1,50 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Daanra\LaravelLetsEncrypt\AcmePhp\Ssl; + +/** + * Represent the response to a certificate request. + * + * @author Jérémy Derussé + */ +class CertificateResponse +{ + /** @var CertificateRequest */ + private $certificateRequest; + + /** @var Certificate */ + private $certificate; + + public function __construct( + CertificateRequest $certificateRequest, + Certificate $certificate + ) { + $this->certificateRequest = $certificateRequest; + $this->certificate = $certificate; + } + + /** + * @return CertificateRequest + */ + public function getCertificateRequest() + { + return $this->certificateRequest; + } + + /** + * @return Certificate + */ + public function getCertificate() + { + return $this->certificate; + } +} diff --git a/src/AcmePhp/Ssl/DistinguishedName.php b/src/AcmePhp/Ssl/DistinguishedName.php new file mode 100644 index 0000000..fe005d5 --- /dev/null +++ b/src/AcmePhp/Ssl/DistinguishedName.php @@ -0,0 +1,151 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Daanra\LaravelLetsEncrypt\AcmePhp\Ssl; + +use Webmozart\Assert\Assert; + +/** + * Represent a Distinguished Name. + * + * @author Jérémy Derussé + */ +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; + + /** + * @param string $commonName + * @param string $countryName + * @param string $stateOrProvinceName + * @param string $localityName + * @param string $organizationName + * @param string $organizationalUnitName + * @param string $emailAddress + */ + public function __construct( + $commonName, + $countryName = null, + $stateOrProvinceName = null, + $localityName = null, + $organizationName = null, + $organizationalUnitName = null, + $emailAddress = null, + array $subjectAlternativeNames = [] + ) { + Assert::stringNotEmpty($commonName, __CLASS__.'::$commonName expected a non empty string. Got: %s'); + Assert::nullOrStringNotEmpty($countryName, __CLASS__.'::$countryName expected a string. Got: %s'); + Assert::nullOrStringNotEmpty($stateOrProvinceName, __CLASS__.'::$stateOrProvinceName expected a string. Got: %s'); + Assert::nullOrStringNotEmpty($localityName, __CLASS__.'::$localityName expected a string. Got: %s'); + Assert::nullOrStringNotEmpty($organizationName, __CLASS__.'::$organizationName expected a string. Got: %s'); + Assert::nullOrStringNotEmpty($organizationalUnitName, __CLASS__.'::$organizationalUnitName expected a string. Got: %s'); + Assert::nullOrStringNotEmpty($emailAddress, __CLASS__.'::$emailAddress expected a 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]); + } + + /** + * @return string + */ + public function getCommonName() + { + return $this->commonName; + } + + /** + * @return string + */ + public function getCountryName() + { + return $this->countryName; + } + + /** + * @return string + */ + public function getStateOrProvinceName() + { + return $this->stateOrProvinceName; + } + + /** + * @return string + */ + public function getLocalityName() + { + return $this->localityName; + } + + /** + * @return string + */ + public function getOrganizationName() + { + return $this->organizationName; + } + + /** + * @return string + */ + public function getOrganizationalUnitName() + { + return $this->organizationalUnitName; + } + + /** + * @return string + */ + public function getEmailAddress() + { + return $this->emailAddress; + } + + /** + * @return array + */ + public function getSubjectAlternativeNames() + { + return $this->subjectAlternativeNames; + } +} diff --git a/src/AcmePhp/Ssl/Exception/AcmeSslException.php b/src/AcmePhp/Ssl/Exception/AcmeSslException.php new file mode 100644 index 0000000..6b3a780 --- /dev/null +++ b/src/AcmePhp/Ssl/Exception/AcmeSslException.php @@ -0,0 +1,19 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Daanra\LaravelLetsEncrypt\AcmePhp\Ssl\Exception; + +/** + * @author Jérémy Derussé + */ +class AcmeSslException extends \RuntimeException +{ +} diff --git a/src/AcmePhp/Ssl/Exception/CSRSigningException.php b/src/AcmePhp/Ssl/Exception/CSRSigningException.php new file mode 100644 index 0000000..72612bf --- /dev/null +++ b/src/AcmePhp/Ssl/Exception/CSRSigningException.php @@ -0,0 +1,19 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Daanra\LaravelLetsEncrypt\AcmePhp\Ssl\Exception; + +/** + * @author Jérémy Derussé + */ +class CSRSigningException extends SigningException +{ +} diff --git a/src/AcmePhp/Ssl/Exception/CertificateFormatException.php b/src/AcmePhp/Ssl/Exception/CertificateFormatException.php new file mode 100644 index 0000000..8895505 --- /dev/null +++ b/src/AcmePhp/Ssl/Exception/CertificateFormatException.php @@ -0,0 +1,19 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Daanra\LaravelLetsEncrypt\AcmePhp\Ssl\Exception; + +/** + * @author Jérémy Derussé + */ +class CertificateFormatException extends ParsingException +{ +} diff --git a/src/AcmePhp/Ssl/Exception/CertificateParsingException.php b/src/AcmePhp/Ssl/Exception/CertificateParsingException.php new file mode 100644 index 0000000..3faa3d8 --- /dev/null +++ b/src/AcmePhp/Ssl/Exception/CertificateParsingException.php @@ -0,0 +1,19 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Daanra\LaravelLetsEncrypt\AcmePhp\Ssl\Exception; + +/** + * @author Jérémy Derussé + */ +class CertificateParsingException extends ParsingException +{ +} diff --git a/src/AcmePhp/Ssl/Exception/DataSigningException.php b/src/AcmePhp/Ssl/Exception/DataSigningException.php new file mode 100644 index 0000000..7581afc --- /dev/null +++ b/src/AcmePhp/Ssl/Exception/DataSigningException.php @@ -0,0 +1,19 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Daanra\LaravelLetsEncrypt\AcmePhp\Ssl\Exception; + +/** + * @author Titouan Galopin + */ +class DataSigningException extends SigningException +{ +} diff --git a/src/AcmePhp/Ssl/Exception/KeyFormatException.php b/src/AcmePhp/Ssl/Exception/KeyFormatException.php new file mode 100644 index 0000000..6a48e91 --- /dev/null +++ b/src/AcmePhp/Ssl/Exception/KeyFormatException.php @@ -0,0 +1,19 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Daanra\LaravelLetsEncrypt\AcmePhp\Ssl\Exception; + +/** + * @author Jérémy Derussé + */ +class KeyFormatException extends ParsingException +{ +} diff --git a/src/AcmePhp/Ssl/Exception/KeyGenerationException.php b/src/AcmePhp/Ssl/Exception/KeyGenerationException.php new file mode 100644 index 0000000..29ff14f --- /dev/null +++ b/src/AcmePhp/Ssl/Exception/KeyGenerationException.php @@ -0,0 +1,19 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Daanra\LaravelLetsEncrypt\AcmePhp\Ssl\Exception; + +/** + * @author Jérémy Derussé + */ +class KeyGenerationException extends AcmeSslException +{ +} diff --git a/src/AcmePhp/Ssl/Exception/KeyPairGenerationException.php b/src/AcmePhp/Ssl/Exception/KeyPairGenerationException.php new file mode 100644 index 0000000..c4d0d69 --- /dev/null +++ b/src/AcmePhp/Ssl/Exception/KeyPairGenerationException.php @@ -0,0 +1,19 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Daanra\LaravelLetsEncrypt\AcmePhp\Ssl\Exception; + +/** + * @author Jérémy Derussé + */ +class KeyPairGenerationException extends KeyGenerationException +{ +} diff --git a/src/AcmePhp/Ssl/Exception/KeyParsingException.php b/src/AcmePhp/Ssl/Exception/KeyParsingException.php new file mode 100644 index 0000000..d57322a --- /dev/null +++ b/src/AcmePhp/Ssl/Exception/KeyParsingException.php @@ -0,0 +1,19 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Daanra\LaravelLetsEncrypt\AcmePhp\Ssl\Exception; + +/** + * @author Titouan Galopin + */ +class KeyParsingException extends ParsingException +{ +} diff --git a/src/AcmePhp/Ssl/Exception/ParsingException.php b/src/AcmePhp/Ssl/Exception/ParsingException.php new file mode 100644 index 0000000..9916e36 --- /dev/null +++ b/src/AcmePhp/Ssl/Exception/ParsingException.php @@ -0,0 +1,19 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Daanra\LaravelLetsEncrypt\AcmePhp\Ssl\Exception; + +/** + * @author Titouan Galopin + */ +class ParsingException extends AcmeSslException +{ +} diff --git a/src/AcmePhp/Ssl/Exception/SigningException.php b/src/AcmePhp/Ssl/Exception/SigningException.php new file mode 100644 index 0000000..b991857 --- /dev/null +++ b/src/AcmePhp/Ssl/Exception/SigningException.php @@ -0,0 +1,19 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Daanra\LaravelLetsEncrypt\AcmePhp\Ssl\Exception; + +/** + * @author Titouan Galopin + */ +class SigningException extends AcmeSslException +{ +} diff --git a/src/AcmePhp/Ssl/Generator/ChainPrivateKeyGenerator.php b/src/AcmePhp/Ssl/Generator/ChainPrivateKeyGenerator.php new file mode 100644 index 0000000..ba6e018 --- /dev/null +++ b/src/AcmePhp/Ssl/Generator/ChainPrivateKeyGenerator.php @@ -0,0 +1,53 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Daanra\LaravelLetsEncrypt\AcmePhp\Ssl\Generator; + +/** + * Generate random RSA private key using OpenSSL. + * + * @author Jérémy Derussé + */ +class ChainPrivateKeyGenerator implements PrivateKeyGeneratorInterface +{ + /** @var PrivateKeyGeneratorInterface[] */ + private $generators; + + /** + * @param PrivateKeyGeneratorInterface[] $generators + */ + public function __construct($generators) + { + $this->generators = $generators; + } + + public function generatePrivateKey(KeyOption $keyOption) + { + 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) + { + foreach ($this->generators as $generator) { + if ($generator->supportsKeyOption($keyOption)) { + return true; + } + } + + return false; + } +} diff --git a/src/AcmePhp/Ssl/Generator/DhKey/DhKeyGenerator.php b/src/AcmePhp/Ssl/Generator/DhKey/DhKeyGenerator.php new file mode 100644 index 0000000..ce4de87 --- /dev/null +++ b/src/AcmePhp/Ssl/Generator/DhKey/DhKeyGenerator.php @@ -0,0 +1,50 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Daanra\LaravelLetsEncrypt\AcmePhp\Ssl\Generator\DhKey; + +use Daanra\LaravelLetsEncrypt\AcmePhp\Ssl\Generator\KeyOption; +use Daanra\LaravelLetsEncrypt\AcmePhp\Ssl\Generator\OpensslPrivateKeyGeneratorTrait; +use Daanra\LaravelLetsEncrypt\AcmePhp\Ssl\Generator\PrivateKeyGeneratorInterface; +use Webmozart\Assert\Assert; + +/** + * Generate random DH private key using OpenSSL. + * + * @author Jérémy Derussé + */ +class DhKeyGenerator implements PrivateKeyGeneratorInterface +{ + use OpensslPrivateKeyGeneratorTrait; + + /** + * @param DhKeyOption|KeyOption $keyOption + */ + public function generatePrivateKey(KeyOption $keyOption) + { + 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) + { + return $keyOption instanceof DhKeyOption; + } +} diff --git a/src/AcmePhp/Ssl/Generator/DhKey/DhKeyOption.php b/src/AcmePhp/Ssl/Generator/DhKey/DhKeyOption.php new file mode 100644 index 0000000..07de368 --- /dev/null +++ b/src/AcmePhp/Ssl/Generator/DhKey/DhKeyOption.php @@ -0,0 +1,50 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Daanra\LaravelLetsEncrypt\AcmePhp\Ssl\Generator\DhKey; + +use Daanra\LaravelLetsEncrypt\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($prime, $generator = '02') + { + $this->generator = pack('H*', $generator); + $this->prime = pack('H*', $prime); + } + + /** + * @return string + */ + public function getGenerator() + { + return $this->generator; + } + + /** + * @return string + */ + public function getPrime() + { + return $this->prime; + } +} diff --git a/src/AcmePhp/Ssl/Generator/DsaKey/DsaKeyGenerator.php b/src/AcmePhp/Ssl/Generator/DsaKey/DsaKeyGenerator.php new file mode 100644 index 0000000..a729c4c --- /dev/null +++ b/src/AcmePhp/Ssl/Generator/DsaKey/DsaKeyGenerator.php @@ -0,0 +1,47 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Daanra\LaravelLetsEncrypt\AcmePhp\Ssl\Generator\DsaKey; + +use Daanra\LaravelLetsEncrypt\AcmePhp\Ssl\Generator\KeyOption; +use Daanra\LaravelLetsEncrypt\AcmePhp\Ssl\Generator\OpensslPrivateKeyGeneratorTrait; +use Daanra\LaravelLetsEncrypt\AcmePhp\Ssl\Generator\PrivateKeyGeneratorInterface; +use Webmozart\Assert\Assert; + +/** + * Generate random DSA private key using OpenSSL. + * + * @author Jérémy Derussé + */ +class DsaKeyGenerator implements PrivateKeyGeneratorInterface +{ + use OpensslPrivateKeyGeneratorTrait; + + /** + * @param DsaKeyOption|KeyOption $keyOption + */ + public function generatePrivateKey(KeyOption $keyOption) + { + Assert::isInstanceOf($keyOption, DsaKeyOption::class); + + return $this->generatePrivateKeyFromOpensslOptions( + [ + 'private_key_type' => OPENSSL_KEYTYPE_DSA, + 'private_key_bits' => $keyOption->getBits(), + ] + ); + } + + public function supportsKeyOption(KeyOption $keyOption) + { + return $keyOption instanceof DsaKeyOption; + } +} diff --git a/src/AcmePhp/Ssl/Generator/DsaKey/DsaKeyOption.php b/src/AcmePhp/Ssl/Generator/DsaKey/DsaKeyOption.php new file mode 100644 index 0000000..6de64a2 --- /dev/null +++ b/src/AcmePhp/Ssl/Generator/DsaKey/DsaKeyOption.php @@ -0,0 +1,36 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Daanra\LaravelLetsEncrypt\AcmePhp\Ssl\Generator\DsaKey; + +use Daanra\LaravelLetsEncrypt\AcmePhp\Ssl\Generator\KeyOption; +use Webmozart\Assert\Assert; + +class DsaKeyOption implements KeyOption +{ + /** @var int */ + private $bits; + + public function __construct($bits = 2048) + { + Assert::integer($bits); + + $this->bits = $bits; + } + + /** + * @return int + */ + public function getBits() + { + return $this->bits; + } +} diff --git a/src/AcmePhp/Ssl/Generator/EcKey/EcKeyGenerator.php b/src/AcmePhp/Ssl/Generator/EcKey/EcKeyGenerator.php new file mode 100644 index 0000000..5a2312f --- /dev/null +++ b/src/AcmePhp/Ssl/Generator/EcKey/EcKeyGenerator.php @@ -0,0 +1,51 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Daanra\LaravelLetsEncrypt\AcmePhp\Ssl\Generator\EcKey; + +use Daanra\LaravelLetsEncrypt\AcmePhp\Ssl\Generator\KeyOption; +use Daanra\LaravelLetsEncrypt\AcmePhp\Ssl\Generator\OpensslPrivateKeyGeneratorTrait; +use Daanra\LaravelLetsEncrypt\AcmePhp\Ssl\Generator\PrivateKeyGeneratorInterface; +use Webmozart\Assert\Assert; + +/** + * Generate random EC private key using OpenSSL. + * + * @author Jérémy Derussé + */ +class EcKeyGenerator implements PrivateKeyGeneratorInterface +{ + use OpensslPrivateKeyGeneratorTrait; + + /** + * @param EcKeyOption|KeyOption $keyOption + */ + public function generatePrivateKey(KeyOption $keyOption) + { + if (\PHP_VERSION_ID < 70100) { + throw new \LogicException('The generation of ECDSA requires a version of PHP >= 7.1'); + } + + Assert::isInstanceOf($keyOption, EcKeyOption::class); + + return $this->generatePrivateKeyFromOpensslOptions( + [ + 'private_key_type' => OPENSSL_KEYTYPE_EC, + 'curve_name' => $keyOption->getCurveName(), + ] + ); + } + + public function supportsKeyOption(KeyOption $keyOption) + { + return $keyOption instanceof EcKeyOption; + } +} diff --git a/src/AcmePhp/Ssl/Generator/EcKey/EcKeyOption.php b/src/AcmePhp/Ssl/Generator/EcKey/EcKeyOption.php new file mode 100644 index 0000000..6d060eb --- /dev/null +++ b/src/AcmePhp/Ssl/Generator/EcKey/EcKeyOption.php @@ -0,0 +1,41 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Daanra\LaravelLetsEncrypt\AcmePhp\Ssl\Generator\EcKey; + +use Daanra\LaravelLetsEncrypt\AcmePhp\Ssl\Generator\KeyOption; +use Webmozart\Assert\Assert; + +class EcKeyOption implements KeyOption +{ + /** @var string */ + private $curveName; + + public function __construct($curveName = 'secp384r1') + { + if (\PHP_VERSION_ID < 70100) { + throw new \LogicException('The generation of ECDSA requires a version of PHP >= 7.1'); + } + + Assert::stringNotEmpty($curveName); + Assert::oneOf($curveName, openssl_get_curve_names(), 'The given curve %s is not supported. Available curves are: %s'); + + $this->curveName = $curveName; + } + + /** + * @return string + */ + public function getCurveName() + { + return $this->curveName; + } +} diff --git a/src/AcmePhp/Ssl/Generator/KeyOption.php b/src/AcmePhp/Ssl/Generator/KeyOption.php new file mode 100644 index 0000000..463cdd2 --- /dev/null +++ b/src/AcmePhp/Ssl/Generator/KeyOption.php @@ -0,0 +1,16 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Daanra\LaravelLetsEncrypt\AcmePhp\Ssl\Generator; + +interface KeyOption +{ +} diff --git a/src/AcmePhp/Ssl/Generator/KeyPairGenerator.php b/src/AcmePhp/Ssl/Generator/KeyPairGenerator.php new file mode 100644 index 0000000..3f05f72 --- /dev/null +++ b/src/AcmePhp/Ssl/Generator/KeyPairGenerator.php @@ -0,0 +1,76 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Daanra\LaravelLetsEncrypt\AcmePhp\Ssl\Generator; + +use Daanra\LaravelLetsEncrypt\AcmePhp\Ssl\Exception\KeyGenerationException; +use Daanra\LaravelLetsEncrypt\AcmePhp\Ssl\Exception\KeyPairGenerationException; +use Daanra\LaravelLetsEncrypt\AcmePhp\Ssl\Generator\DhKey\DhKeyGenerator; +use Daanra\LaravelLetsEncrypt\AcmePhp\Ssl\Generator\DsaKey\DsaKeyGenerator; +use Daanra\LaravelLetsEncrypt\AcmePhp\Ssl\Generator\EcKey\EcKeyGenerator; +use Daanra\LaravelLetsEncrypt\AcmePhp\Ssl\Generator\RsaKey\RsaKeyGenerator; +use Daanra\LaravelLetsEncrypt\AcmePhp\Ssl\Generator\RsaKey\RsaKeyOption; +use Daanra\LaravelLetsEncrypt\AcmePhp\Ssl\KeyPair; +use Webmozart\Assert\Assert; + +/** + * Generate random KeyPair using OpenSSL. + * + * @author Jérémy Derussé + */ +class KeyPairGenerator +{ + private $generator; + + public function __construct(PrivateKeyGeneratorInterface $generator = null) + { + $this->generator = $generator ?: new ChainPrivateKeyGenerator( + [ + new RsaKeyGenerator(), + new EcKeyGenerator(), + new DhKeyGenerator(), + new DsaKeyGenerator(), + ] + ); + } + + /** + * Generate KeyPair. + * + * @param KeyOption $keyOption configuration of the key to generate + * + * @throws KeyPairGenerationException when OpenSSL failed to generate keys + * + * @return KeyPair + */ + public function generateKeyPair($keyOption = null) + { + if (null === $keyOption) { + $keyOption = new RsaKeyOption(); + } + if (\is_int($keyOption)) { + @trigger_error('Passing a keySize to "generateKeyPair" is deprecated since version 1.1 and will be removed in 2.0. Pass an instance of KeyOption instead', E_USER_DEPRECATED); + $keyOption = new RsaKeyOption($keyOption); + } + Assert::isInstanceOf($keyOption, KeyOption::class); + + 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 + ); + } +} diff --git a/src/AcmePhp/Ssl/Generator/OpensslPrivateKeyGeneratorTrait.php b/src/AcmePhp/Ssl/Generator/OpensslPrivateKeyGeneratorTrait.php new file mode 100644 index 0000000..22658f1 --- /dev/null +++ b/src/AcmePhp/Ssl/Generator/OpensslPrivateKeyGeneratorTrait.php @@ -0,0 +1,38 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Daanra\LaravelLetsEncrypt\AcmePhp\Ssl\Generator; + +use Daanra\LaravelLetsEncrypt\AcmePhp\Ssl\Exception\KeyGenerationException; +use Daanra\LaravelLetsEncrypt\AcmePhp\Ssl\Exception\KeyPairGenerationException; +use Daanra\LaravelLetsEncrypt\AcmePhp\Ssl\PrivateKey; + +trait OpensslPrivateKeyGeneratorTrait +{ + private function generatePrivateKeyFromOpensslOptions(array $opensslOptions) + { + $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); + } +} diff --git a/src/AcmePhp/Ssl/Generator/PrivateKeyGeneratorInterface.php b/src/AcmePhp/Ssl/Generator/PrivateKeyGeneratorInterface.php new file mode 100644 index 0000000..3c4c10e --- /dev/null +++ b/src/AcmePhp/Ssl/Generator/PrivateKeyGeneratorInterface.php @@ -0,0 +1,43 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Daanra\LaravelLetsEncrypt\AcmePhp\Ssl\Generator; + +use Daanra\LaravelLetsEncrypt\AcmePhp\Ssl\Exception\KeyGenerationException; +use Daanra\LaravelLetsEncrypt\AcmePhp\Ssl\PrivateKey; + +/** + * Generate random private key. + * + * @author Jérémy Derussé + */ +interface PrivateKeyGeneratorInterface +{ + /** + * Generate a PrivateKey. + * + * @param KeyOption $keyOption configuration of the key to generate + * + * @throws KeyGenerationException when OpenSSL failed to generate keys + * + * @return PrivateKey + */ + public function generatePrivateKey(KeyOption $keyOption); + + /** + * Returns whether the instance is able to generator a private key from the given option. + * + * @param KeyOption $keyOption configuration of the key to generate + * + * @return bool + */ + public function supportsKeyOption(KeyOption $keyOption); +} diff --git a/src/AcmePhp/Ssl/Generator/RsaKey/RsaKeyGenerator.php b/src/AcmePhp/Ssl/Generator/RsaKey/RsaKeyGenerator.php new file mode 100644 index 0000000..411a59a --- /dev/null +++ b/src/AcmePhp/Ssl/Generator/RsaKey/RsaKeyGenerator.php @@ -0,0 +1,47 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Daanra\LaravelLetsEncrypt\AcmePhp\Ssl\Generator\RsaKey; + +use Daanra\LaravelLetsEncrypt\AcmePhp\Ssl\Generator\KeyOption; +use Daanra\LaravelLetsEncrypt\AcmePhp\Ssl\Generator\OpensslPrivateKeyGeneratorTrait; +use Daanra\LaravelLetsEncrypt\AcmePhp\Ssl\Generator\PrivateKeyGeneratorInterface; +use Webmozart\Assert\Assert; + +/** + * Generate random RSA private key using OpenSSL. + * + * @author Jérémy Derussé + */ +class RsaKeyGenerator implements PrivateKeyGeneratorInterface +{ + use OpensslPrivateKeyGeneratorTrait; + + /** + * @param RsaKeyOption|KeyOption $keyOption + */ + public function generatePrivateKey(KeyOption $keyOption) + { + Assert::isInstanceOf($keyOption, RsaKeyOption::class); + + return $this->generatePrivateKeyFromOpensslOptions( + [ + 'private_key_type' => OPENSSL_KEYTYPE_RSA, + 'private_key_bits' => $keyOption->getBits(), + ] + ); + } + + public function supportsKeyOption(KeyOption $keyOption) + { + return $keyOption instanceof RsaKeyOption; + } +} diff --git a/src/AcmePhp/Ssl/Generator/RsaKey/RsaKeyOption.php b/src/AcmePhp/Ssl/Generator/RsaKey/RsaKeyOption.php new file mode 100644 index 0000000..a65a0e2 --- /dev/null +++ b/src/AcmePhp/Ssl/Generator/RsaKey/RsaKeyOption.php @@ -0,0 +1,36 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Daanra\LaravelLetsEncrypt\AcmePhp\Ssl\Generator\RsaKey; + +use Daanra\LaravelLetsEncrypt\AcmePhp\Ssl\Generator\KeyOption; +use Webmozart\Assert\Assert; + +class RsaKeyOption implements KeyOption +{ + /** @var int */ + private $bits; + + public function __construct($bits = 4096) + { + Assert::integer($bits); + + $this->bits = $bits; + } + + /** + * @return int + */ + public function getBits() + { + return $this->bits; + } +} diff --git a/src/AcmePhp/Ssl/Key.php b/src/AcmePhp/Ssl/Key.php new file mode 100644 index 0000000..f9d446e --- /dev/null +++ b/src/AcmePhp/Ssl/Key.php @@ -0,0 +1,62 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Daanra\LaravelLetsEncrypt\AcmePhp\Ssl; + +use Webmozart\Assert\Assert; + +/** + * Represent a SSL key. + * + * @author Jérémy Derussé + */ +abstract class Key +{ + /** @var string */ + protected $keyPEM; + + /** + * @param string $keyPEM + */ + public function __construct($keyPEM) + { + Assert::stringNotEmpty($keyPEM, __CLASS__.'::$keyPEM should not be an empty string. Got %s'); + + $this->keyPEM = $keyPEM; + } + + /** + * @return string + */ + public function getPEM() + { + return $this->keyPEM; + } + + /** + * @return string + */ + public function getDER() + { + $lines = explode("\n", trim($this->keyPEM)); + unset($lines[\count($lines) - 1]); + unset($lines[0]); + $result = implode('', $lines); + $result = base64_decode($result); + + return $result; + } + + /** + * @return resource + */ + abstract public function getResource(); +} diff --git a/src/AcmePhp/Ssl/KeyPair.php b/src/AcmePhp/Ssl/KeyPair.php new file mode 100644 index 0000000..37aaf06 --- /dev/null +++ b/src/AcmePhp/Ssl/KeyPair.php @@ -0,0 +1,48 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Daanra\LaravelLetsEncrypt\AcmePhp\Ssl; + +/** + * Represent a SSL key-pair (public and private). + * + * @author Titouan Galopin + */ +class KeyPair +{ + /** @var PublicKey */ + private $publicKey; + + /** @var PrivateKey */ + private $privateKey; + + public function __construct(PublicKey $publicKey, PrivateKey $privateKey) + { + $this->publicKey = $publicKey; + $this->privateKey = $privateKey; + } + + /** + * @return PublicKey + */ + public function getPublicKey() + { + return $this->publicKey; + } + + /** + * @return PrivateKey + */ + public function getPrivateKey() + { + return $this->privateKey; + } +} diff --git a/src/AcmePhp/Ssl/ParsedCertificate.php b/src/AcmePhp/Ssl/ParsedCertificate.php new file mode 100644 index 0000000..eb5707b --- /dev/null +++ b/src/AcmePhp/Ssl/ParsedCertificate.php @@ -0,0 +1,155 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Daanra\LaravelLetsEncrypt\AcmePhp\Ssl; + +use Webmozart\Assert\Assert; + +/** + * Represent the content of a parsed certificate. + * + * @author Jérémy Derussé + */ +class ParsedCertificate +{ + /** @var Certificate */ + private $source; + + /** @var string */ + private $subject; + + /** @var string */ + private $issuer; + + /** @var bool */ + private $selfSigned; + + /** @var \DateTime */ + private $validFrom; + + /** @var \DateTime */ + private $validTo; + + /** @var string */ + private $serialNumber; + + /** @var array */ + private $subjectAlternativeNames; + + /** + * @param string $subject + * @param string $issuer + * @param bool $selfSigned + * @param \DateTime $validFrom + * @param \DateTime $validTo + * @param string $serialNumber + */ + public function __construct( + Certificate $source, + $subject, + $issuer = null, + $selfSigned = true, + \DateTime $validFrom = null, + \DateTime $validTo = null, + $serialNumber = null, + array $subjectAlternativeNames = [] + ) { + Assert::stringNotEmpty($subject, __CLASS__.'::$subject expected a non empty string. Got: %s'); + Assert::nullOrString($issuer, __CLASS__.'::$issuer expected a string or null. Got: %s'); + Assert::nullOrBoolean($selfSigned, __CLASS__.'::$selfSigned expected a boolean or null. Got: %s'); + Assert::nullOrString($serialNumber, __CLASS__.'::$serialNumber expected a string or null. Got: %s'); + Assert::allStringNotEmpty( + $subjectAlternativeNames, + __CLASS__.'::$subjectAlternativeNames expected a array of non empty string. Got: %s' + ); + + $this->source = $source; + $this->subject = $subject; + $this->issuer = $issuer; + $this->selfSigned = $selfSigned; + $this->validFrom = $validFrom; + $this->validTo = $validTo; + $this->serialNumber = $serialNumber; + $this->subjectAlternativeNames = $subjectAlternativeNames; + } + + /** + * @return Certificate + */ + public function getSource() + { + return $this->source; + } + + /** + * @return string + */ + public function getSubject() + { + return $this->subject; + } + + /** + * @return string + */ + public function getIssuer() + { + return $this->issuer; + } + + /** + * @return bool + */ + public function isSelfSigned() + { + return $this->selfSigned; + } + + /** + * @return \DateTime + */ + public function getValidFrom() + { + return $this->validFrom; + } + + /** + * @return \DateTime + */ + public function getValidTo() + { + return $this->validTo; + } + + /** + * @return bool + */ + public function isExpired() + { + return $this->validTo < (new \DateTime()); + } + + /** + * @return string + */ + public function getSerialNumber() + { + return $this->serialNumber; + } + + /** + * @return array + */ + public function getSubjectAlternativeNames() + { + return $this->subjectAlternativeNames; + } +} diff --git a/src/AcmePhp/Ssl/ParsedKey.php b/src/AcmePhp/Ssl/ParsedKey.php new file mode 100644 index 0000000..3813d45 --- /dev/null +++ b/src/AcmePhp/Ssl/ParsedKey.php @@ -0,0 +1,123 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Daanra\LaravelLetsEncrypt\AcmePhp\Ssl; + +use Webmozart\Assert\Assert; + +/** + * Represent the content of a parsed key. + * + * @see openssl_pkey_get_details + * + * @author Titouan Galopin + */ +class ParsedKey +{ + /** @var Key */ + private $source; + + /** @var string */ + private $key; + + /** @var int */ + private $bits; + + /** @var int */ + private $type; + + /** @var array */ + private $details; + + /** + * @param string $key + * @param int $bits + * @param int $type + */ + public function __construct(Key $source, $key, $bits, $type, array $details = []) + { + Assert::stringNotEmpty($key, __CLASS__.'::$key expected a non empty string. Got: %s'); + Assert::integer($bits, __CLASS__.'::$bits expected an integer. Got: %s'); + Assert::oneOf( + $type, + [OPENSSL_KEYTYPE_RSA, OPENSSL_KEYTYPE_DSA, OPENSSL_KEYTYPE_DH, OPENSSL_KEYTYPE_EC], + __CLASS__.'::$type expected one of: %2$s. Got: %s' + ); + + $this->source = $source; + $this->key = $key; + $this->bits = $bits; + $this->type = $type; + $this->details = $details; + } + + /** + * @return Key + */ + public function getSource() + { + return $this->source; + } + + /** + * @return string + */ + public function getKey() + { + return $this->key; + } + + /** + * @return int + */ + public function getBits() + { + return $this->bits; + } + + /** + * @return int + */ + public function getType() + { + return $this->type; + } + + /** + * @return array + */ + public function getDetails() + { + return $this->details; + } + + /** + * @param string $name + * + * @return bool + */ + public function hasDetail($name) + { + return isset($this->details[$name]); + } + + /** + * @param string $name + * + * @return mixed + */ + public function getDetail($name) + { + Assert::oneOf($name, array_keys($this->details), 'ParsedKey::getDetail() expected one of: %2$s. Got: %s'); + + return $this->details[$name]; + } +} diff --git a/src/AcmePhp/Ssl/Parser/CertificateParser.php b/src/AcmePhp/Ssl/Parser/CertificateParser.php new file mode 100644 index 0000000..e4246c8 --- /dev/null +++ b/src/AcmePhp/Ssl/Parser/CertificateParser.php @@ -0,0 +1,81 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Daanra\LaravelLetsEncrypt\AcmePhp\Ssl\Parser; + +use Daanra\LaravelLetsEncrypt\AcmePhp\Ssl\Certificate; +use Daanra\LaravelLetsEncrypt\AcmePhp\Ssl\Exception\CertificateParsingException; +use Daanra\LaravelLetsEncrypt\AcmePhp\Ssl\ParsedCertificate; + +/** + * Parse certificate to extract metadata. + * + * @author Jérémy Derussé + */ +class CertificateParser +{ + /** + * Parse the certificate. + * + * @return ParsedCertificate + */ + public function parse(Certificate $certificate) + { + $rawData = openssl_x509_parse($certificate->getPEM()); + + if (!\is_array($rawData)) { + throw new CertificateParsingException(sprintf('Fail to parse certificate with error: %s', openssl_error_string())); + } + + if (!isset($rawData['subject']['CN'])) { + throw new CertificateParsingException('Missing expected key "subject.cn" in certificate'); + } + if (!isset($rawData['serialNumber'])) { + throw new CertificateParsingException('Missing expected key "serialNumber" in certificate'); + } + if (!isset($rawData['validFrom_time_t'])) { + throw new CertificateParsingException('Missing expected key "validFrom_time_t" in certificate'); + } + if (!isset($rawData['validTo_time_t'])) { + throw new CertificateParsingException('Missing expected key "validTo_time_t" in certificate'); + } + + $subjectAlternativeName = []; + + if (isset($rawData['extensions']['subjectAltName'])) { + $subjectAlternativeName = array_map( + function ($item) { + return explode(':', trim($item), 2)[1]; + }, + array_filter( + explode( + ',', + $rawData['extensions']['subjectAltName'] + ), + function ($item) { + return false !== strpos($item, ':'); + } + ) + ); + } + + return new ParsedCertificate( + $certificate, + $rawData['subject']['CN'], + isset($rawData['issuer']['CN']) ? $rawData['issuer']['CN'] : null, + $rawData['subject'] === $rawData['issuer'], + new \DateTime('@'.$rawData['validFrom_time_t']), + new \DateTime('@'.$rawData['validTo_time_t']), + $rawData['serialNumber'], + $subjectAlternativeName + ); + } +} diff --git a/src/AcmePhp/Ssl/Parser/KeyParser.php b/src/AcmePhp/Ssl/Parser/KeyParser.php new file mode 100644 index 0000000..8a7ddd2 --- /dev/null +++ b/src/AcmePhp/Ssl/Parser/KeyParser.php @@ -0,0 +1,70 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Daanra\LaravelLetsEncrypt\AcmePhp\Ssl\Parser; + +use Daanra\LaravelLetsEncrypt\AcmePhp\Ssl\Exception\KeyFormatException; +use Daanra\LaravelLetsEncrypt\AcmePhp\Ssl\Exception\KeyParsingException; +use Daanra\LaravelLetsEncrypt\AcmePhp\Ssl\Key; +use Daanra\LaravelLetsEncrypt\AcmePhp\Ssl\ParsedKey; + +/** + * Parse keys to extract metadata. + * + * @author Titouan Galopin + */ +class KeyParser +{ + /** + * Parse the key. + * + * @return ParsedKey + */ + public function parse(Key $key) + { + try { + $resource = $key->getResource(); + } catch (KeyFormatException $e) { + throw new KeyParsingException('Fail to load resource for key', 0, $e); + } + + $rawData = openssl_pkey_get_details($resource); + + // PHP 8 automatically frees the key instance and deprecates the function + if (\PHP_VERSION_ID < 80000) { + openssl_free_key($resource); + } + + if (!\is_array($rawData)) { + throw new KeyParsingException(sprintf('Fail to parse key with error: %s', openssl_error_string())); + } + + foreach (['type', 'key', 'bits'] as $requiredKey) { + if (!isset($rawData[$requiredKey])) { + throw new KeyParsingException(sprintf('Missing expected key "%s" in OpenSSL key', $requiredKey)); + } + } + + $details = []; + + if (OPENSSL_KEYTYPE_RSA === $rawData['type']) { + $details = $rawData['rsa']; + } elseif (OPENSSL_KEYTYPE_DSA === $rawData['type']) { + $details = $rawData['dsa']; + } elseif (OPENSSL_KEYTYPE_DH === $rawData['type']) { + $details = $rawData['dh']; + } elseif (OPENSSL_KEYTYPE_EC === $rawData['type']) { + $details = $rawData['ec']; + } + + return new ParsedKey($key, $rawData['key'], $rawData['bits'], $rawData['type'], $details); + } +} diff --git a/src/AcmePhp/Ssl/PrivateKey.php b/src/AcmePhp/Ssl/PrivateKey.php new file mode 100644 index 0000000..ff42261 --- /dev/null +++ b/src/AcmePhp/Ssl/PrivateKey.php @@ -0,0 +1,71 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Daanra\LaravelLetsEncrypt\AcmePhp\Ssl; + +use Daanra\LaravelLetsEncrypt\AcmePhp\Ssl\Exception\KeyFormatException; +use Webmozart\Assert\Assert; + +/** + * Represent a SSL Private key. + * + * @author Jérémy Derussé + */ +class PrivateKey extends Key +{ + /** + * {@inheritdoc} + */ + public function getResource() + { + if (!$resource = openssl_pkey_get_private($this->keyPEM)) { + throw new KeyFormatException(sprintf('Failed to convert key into resource: %s', openssl_error_string())); + } + + return $resource; + } + + /** + * @return PublicKey + */ + public function getPublicKey() + { + $resource = $this->getResource(); + if (!$details = openssl_pkey_get_details($resource)) { + throw new KeyFormatException(sprintf('Failed to extract public key: %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 PublicKey($details['key']); + } + + /** + * @param $keyDER + * + * @return PrivateKey + */ + public static function fromDER($keyDER) + { + Assert::stringNotEmpty($keyDER, __METHOD__.'::$keyDER should be a non-empty string. Got %s'); + + $der = base64_encode($keyDER); + $lines = str_split($der, 65); + array_unshift($lines, '-----BEGIN PRIVATE KEY-----'); + $lines[] = '-----END PRIVATE KEY-----'; + $lines[] = ''; + + return new self(implode("\n", $lines)); + } +} diff --git a/src/AcmePhp/Ssl/PublicKey.php b/src/AcmePhp/Ssl/PublicKey.php new file mode 100644 index 0000000..57bba60 --- /dev/null +++ b/src/AcmePhp/Ssl/PublicKey.php @@ -0,0 +1,61 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Daanra\LaravelLetsEncrypt\AcmePhp\Ssl; + +use Daanra\LaravelLetsEncrypt\AcmePhp\Ssl\Exception\KeyFormatException; +use Webmozart\Assert\Assert; + +/** + * Represent a SSL Public key. + * + * @author Jérémy Derussé + */ +class PublicKey extends Key +{ + /** + * {@inheritdoc} + */ + public function getResource() + { + if (!$resource = openssl_pkey_get_public($this->keyPEM)) { + throw new KeyFormatException(sprintf('Failed to convert key into resource: %s', openssl_error_string())); + } + + return $resource; + } + + /** + * @param $keyDER + * + * @return PublicKey + */ + public static function fromDER($keyDER) + { + Assert::stringNotEmpty($keyDER, __METHOD__.'::$keyDER should be a non-empty string. Got %s'); + + $der = base64_encode($keyDER); + $lines = str_split($der, 65); + array_unshift($lines, '-----BEGIN PUBLIC KEY-----'); + $lines[] = '-----END PUBLIC KEY-----'; + $lines[] = ''; + + return new self(implode("\n", $lines)); + } + + /** + * @return string + */ + public function getHPKP() + { + return base64_encode(hash('sha256', $this->getDER(), true)); + } +} diff --git a/src/AcmePhp/Ssl/Signer/CertificateRequestSigner.php b/src/AcmePhp/Ssl/Signer/CertificateRequestSigner.php new file mode 100644 index 0000000..5e9b335 --- /dev/null +++ b/src/AcmePhp/Ssl/Signer/CertificateRequestSigner.php @@ -0,0 +1,136 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Daanra\LaravelLetsEncrypt\AcmePhp\Ssl\Signer; + +use Daanra\LaravelLetsEncrypt\AcmePhp\Ssl\CertificateRequest; +use Daanra\LaravelLetsEncrypt\AcmePhp\Ssl\DistinguishedName; +use Daanra\LaravelLetsEncrypt\AcmePhp\Ssl\Exception\CSRSigningException; + +/** + * Provide tools to sign certificate request. + * + * @author Jérémy Derussé + */ +class CertificateRequestSigner +{ + /** + * Generate a CSR from the given distinguishedName and keyPair. + * + * @return string + */ + public function signCertificateRequest(CertificateRequest $certificateRequest) + { + $csrObject = $this->createCsrWithSANsObject($certificateRequest); + + if (!$csrObject || !openssl_csr_export($csrObject, $csrExport)) { + throw new CSRSigningException(sprintf('OpenSSL CSR signing failed with error: %s', openssl_error_string())); + } + + return $csrExport; + } + + /** + * Generate a CSR object with SANs from the given distinguishedName and keyPair. + * + * @return mixed + */ + protected function createCsrWithSANsObject(CertificateRequest $certificateRequest) + { + $sslConfigTemplate = <<<'EOL' +[ req ] +distinguished_name = req_distinguished_name +req_extensions = v3_req +[ req_distinguished_name ] +[ v3_req ] +basicConstraints = CA:FALSE +keyUsage = nonRepudiation, digitalSignature, keyEncipherment +subjectAltName = @req_subject_alt_name +[ req_subject_alt_name ] +%s +EOL; + $sslConfigDomains = []; + + $distinguishedName = $certificateRequest->getDistinguishedName(); + $domains = array_merge( + [$distinguishedName->getCommonName()], + $distinguishedName->getSubjectAlternativeNames() + ); + + foreach (array_values($domains) as $index => $domain) { + $sslConfigDomains[] = 'DNS.'.($index + 1).' = '.$domain; + } + + $sslConfigContent = sprintf($sslConfigTemplate, implode("\n", $sslConfigDomains)); + $sslConfigFile = tempnam(sys_get_temp_dir(), 'acmephp_'); + + try { + file_put_contents($sslConfigFile, $sslConfigContent); + + $resource = $certificateRequest->getKeyPair()->getPrivateKey()->getResource(); + + $csr = openssl_csr_new( + $this->getCSRPayload($distinguishedName), + $resource, + [ + 'digest_alg' => 'sha256', + 'config' => $sslConfigFile, + ] + ); + + // PHP 8 automatically frees the key instance and deprecates the function + if (\PHP_VERSION_ID < 80000) { + openssl_free_key($resource); + } + + if (!$csr) { + throw new CSRSigningException(sprintf('OpenSSL CSR signing failed with error: %s', openssl_error_string())); + } + + return $csr; + } finally { + unlink($sslConfigFile); + } + } + + /** + * Retrieves a CSR payload from the given distinguished name. + * + * @return array + */ + private function getCSRPayload(DistinguishedName $distinguishedName) + { + $payload = []; + if (null !== $countryName = $distinguishedName->getCountryName()) { + $payload['countryName'] = $countryName; + } + if (null !== $stateOrProvinceName = $distinguishedName->getStateOrProvinceName()) { + $payload['stateOrProvinceName'] = $stateOrProvinceName; + } + if (null !== $localityName = $distinguishedName->getLocalityName()) { + $payload['localityName'] = $localityName; + } + if (null !== $OrganizationName = $distinguishedName->getOrganizationName()) { + $payload['organizationName'] = $OrganizationName; + } + if (null !== $organizationUnitName = $distinguishedName->getOrganizationalUnitName()) { + $payload['organizationalUnitName'] = $organizationUnitName; + } + if (null !== $commonName = $distinguishedName->getCommonName()) { + $payload['commonName'] = $commonName; + } + if (null !== $emailAddress = $distinguishedName->getEmailAddress()) { + $payload['emailAddress'] = $emailAddress; + } + + return $payload; + } +} diff --git a/src/AcmePhp/Ssl/Signer/DataSigner.php b/src/AcmePhp/Ssl/Signer/DataSigner.php new file mode 100644 index 0000000..4d2f8ff --- /dev/null +++ b/src/AcmePhp/Ssl/Signer/DataSigner.php @@ -0,0 +1,137 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Daanra\LaravelLetsEncrypt\AcmePhp\Ssl\Signer; + +use Daanra\LaravelLetsEncrypt\AcmePhp\Ssl\Exception\DataSigningException; +use Daanra\LaravelLetsEncrypt\AcmePhp\Ssl\PrivateKey; +use Webmozart\Assert\Assert; + +/** + * Provide tools to sign data using a private key. + * + * @author Titouan Galopin + */ +class DataSigner +{ + const FORMAT_DER = 'DER'; + const FORMAT_ECDSA = 'ECDSA'; + + /** + * Generate a signature of the given data using a private key and an algorithm. + * + * @param string $data Data to sign + * @param PrivateKey $privateKey Key used to sign + * @param int $algorithm Signature algorithm defined by constants OPENSSL_ALGO_* + * @param string $format Format of the output + * + * @return string + */ + public function signData($data, PrivateKey $privateKey, $algorithm = OPENSSL_ALGO_SHA256, $format = self::FORMAT_DER) + { + Assert::oneOf($format, [self::FORMAT_ECDSA, self::FORMAT_DER], 'The format %s to sign request does not exists. Available format: %s'); + + $resource = $privateKey->getResource(); + if (!openssl_sign($data, $signature, $resource, $algorithm)) { + throw new DataSigningException(sprintf('OpenSSL data signing failed 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); + } + + switch ($format) { + case self::FORMAT_DER: + return $signature; + case self::FORMAT_ECDSA: + switch ($algorithm) { + case OPENSSL_ALGO_SHA256: + return $this->DERtoECDSA($signature, 64); + case OPENSSL_ALGO_SHA384: + return $this->DERtoECDSA($signature, 96); + case OPENSSL_ALGO_SHA512: + return $this->DERtoECDSA($signature, 132); + } + throw new DataSigningException('Unable to generate a ECDSA signature with the given algorithm'); + default: + throw new DataSigningException('The given format does exists'); + } + } + + /** + * Convert a DER signature into ECDSA. + * + * The code is a copy/paste from another lib (web-token/jwt-core) which is not compatible with php <= 7.0 + * + * @see https://github.com/web-token/jwt-core/blob/master/Util/ECSignature.php + */ + private function DERtoECDSA($der, $partLength) + { + $hex = unpack('H*', $der)[1]; + if ('30' !== mb_substr($hex, 0, 2, '8bit')) { // SEQUENCE + throw new DataSigningException('Invalid signature provided'); + } + if ('81' === mb_substr($hex, 2, 2, '8bit')) { // LENGTH > 128 + $hex = mb_substr($hex, 6, null, '8bit'); + } else { + $hex = mb_substr($hex, 4, null, '8bit'); + } + if ('02' !== mb_substr($hex, 0, 2, '8bit')) { // INTEGER + throw new DataSigningException('Invalid signature provided'); + } + + $Rl = hexdec(mb_substr($hex, 2, 2, '8bit')); + $R = $this->retrievePositiveInteger(mb_substr($hex, 4, $Rl * 2, '8bit')); + $R = str_pad($R, $partLength, '0', STR_PAD_LEFT); + + $hex = mb_substr($hex, 4 + $Rl * 2, null, '8bit'); + if ('02' !== mb_substr($hex, 0, 2, '8bit')) { // INTEGER + throw new DataSigningException('Invalid signature provided'); + } + $Sl = hexdec(mb_substr($hex, 2, 2, '8bit')); + $S = $this->retrievePositiveInteger(mb_substr($hex, 4, $Sl * 2, '8bit')); + $S = str_pad($S, $partLength, '0', STR_PAD_LEFT); + + return pack('H*', $R.$S); + } + + /** + * The code is a copy/paste from another lib (web-token/jwt-core) which is not compatible with php <= 7.0. + * + * @see https://github.com/web-token/jwt-core/blob/master/Util/ECSignature.php + */ + private function preparePositiveInteger($data) + { + if (mb_substr($data, 0, 2, '8bit') > '7f') { + return '00'.$data; + } + while ('00' === mb_substr($data, 0, 2, '8bit') && mb_substr($data, 2, 2, '8bit') <= '7f') { + $data = mb_substr($data, 2, null, '8bit'); + } + + return $data; + } + + /** + * The code is a copy/paste from another lib (web-token/jwt-core) which is not compatible with php <= 7.0. + * + * @see https://github.com/web-token/jwt-core/blob/master/Util/ECSignature.php + */ + private function retrievePositiveInteger($data) + { + while ('00' === mb_substr($data, 0, 2, '8bit') && mb_substr($data, 2, 2, '8bit') > '7f') { + $data = mb_substr($data, 2, null, '8bit'); + } + + return $data; + } +} diff --git a/src/Jobs/ChallengeAuthorization.php b/src/Jobs/ChallengeAuthorization.php index 4da471e..1954aa5 100644 --- a/src/Jobs/ChallengeAuthorization.php +++ b/src/Jobs/ChallengeAuthorization.php @@ -2,7 +2,7 @@ namespace Daanra\LaravelLetsEncrypt\Jobs; -use AcmePhp\Core\Protocol\AuthorizationChallenge; +use Daanra\LaravelLetsEncrypt\AcmePhp\Core\Protocol\AuthorizationChallenge; use Daanra\LaravelLetsEncrypt\Events\ChallengeAuthorizationFailed; use Daanra\LaravelLetsEncrypt\Facades\LetsEncrypt; use Daanra\LaravelLetsEncrypt\Traits\Retryable; diff --git a/src/Jobs/CleanUpChallenge.php b/src/Jobs/CleanUpChallenge.php index e19fba1..a799a26 100644 --- a/src/Jobs/CleanUpChallenge.php +++ b/src/Jobs/CleanUpChallenge.php @@ -2,8 +2,8 @@ namespace Daanra\LaravelLetsEncrypt\Jobs; -use AcmePhp\Core\AcmeClient; -use AcmePhp\Core\Protocol\AuthorizationChallenge; +use Daanra\LaravelLetsEncrypt\AcmePhp\Core\AcmeClient; +use Daanra\LaravelLetsEncrypt\AcmePhp\Core\Protocol\AuthorizationChallenge; use Daanra\LaravelLetsEncrypt\Events\CleanUpChallengeFailed; use Daanra\LaravelLetsEncrypt\Support\PathGeneratorFactory; use Daanra\LaravelLetsEncrypt\Traits\Retryable; diff --git a/src/Jobs/RequestAuthorization.php b/src/Jobs/RequestAuthorization.php index ce22fd0..79a1549 100644 --- a/src/Jobs/RequestAuthorization.php +++ b/src/Jobs/RequestAuthorization.php @@ -2,7 +2,7 @@ namespace Daanra\LaravelLetsEncrypt\Jobs; -use AcmePhp\Core\Protocol\AuthorizationChallenge; +use Daanra\LaravelLetsEncrypt\AcmePhp\Core\Protocol\AuthorizationChallenge; use Daanra\LaravelLetsEncrypt\Events\RequestAuthorizationFailed; use Daanra\LaravelLetsEncrypt\Exceptions\FailedToMoveChallengeException; use Daanra\LaravelLetsEncrypt\Facades\LetsEncrypt; diff --git a/src/Jobs/RequestCertificate.php b/src/Jobs/RequestCertificate.php index 171b695..40023fb 100644 --- a/src/Jobs/RequestCertificate.php +++ b/src/Jobs/RequestCertificate.php @@ -2,9 +2,9 @@ namespace Daanra\LaravelLetsEncrypt\Jobs; -use AcmePhp\Ssl\CertificateRequest; -use AcmePhp\Ssl\DistinguishedName; -use AcmePhp\Ssl\Generator\KeyPairGenerator; +use Daanra\LaravelLetsEncrypt\AcmePhp\Ssl\CertificateRequest; +use Daanra\LaravelLetsEncrypt\AcmePhp\Ssl\DistinguishedName; +use Daanra\LaravelLetsEncrypt\AcmePhp\Ssl\Generator\KeyPairGenerator; use Daanra\LaravelLetsEncrypt\Events\RequestCertificateFailed; use Daanra\LaravelLetsEncrypt\Facades\LetsEncrypt; use Daanra\LaravelLetsEncrypt\Models\LetsEncryptCertificate; diff --git a/src/Jobs/StoreCertificate.php b/src/Jobs/StoreCertificate.php index 9bb19ad..1d3dc44 100644 --- a/src/Jobs/StoreCertificate.php +++ b/src/Jobs/StoreCertificate.php @@ -2,8 +2,8 @@ namespace Daanra\LaravelLetsEncrypt\Jobs; -use AcmePhp\Ssl\Certificate; -use AcmePhp\Ssl\PrivateKey; +use Daanra\LaravelLetsEncrypt\AcmePhp\Ssl\Certificate; +use Daanra\LaravelLetsEncrypt\AcmePhp\Ssl\PrivateKey; use Daanra\LaravelLetsEncrypt\Contracts\PathGenerator; use Daanra\LaravelLetsEncrypt\Encoders\PemEncoder; use Daanra\LaravelLetsEncrypt\Events\StoreCertificateFailed; diff --git a/src/LetsEncrypt.php b/src/LetsEncrypt.php index c40e049..e5aa9cc 100755 --- a/src/LetsEncrypt.php +++ b/src/LetsEncrypt.php @@ -2,12 +2,12 @@ namespace Daanra\LaravelLetsEncrypt; -use AcmePhp\Core\AcmeClient; -use AcmePhp\Core\Http\SecureHttpClientFactory; -use AcmePhp\Ssl\Generator\KeyPairGenerator; -use AcmePhp\Ssl\KeyPair; -use AcmePhp\Ssl\PrivateKey; -use AcmePhp\Ssl\PublicKey; +use Daanra\LaravelLetsEncrypt\AcmePhp\Core\AcmeClient; +use Daanra\LaravelLetsEncrypt\AcmePhp\Core\Http\SecureHttpClientFactory; +use Daanra\LaravelLetsEncrypt\AcmePhp\Ssl\Generator\KeyPairGenerator; +use Daanra\LaravelLetsEncrypt\AcmePhp\Ssl\KeyPair; +use Daanra\LaravelLetsEncrypt\AcmePhp\Ssl\PrivateKey; +use Daanra\LaravelLetsEncrypt\AcmePhp\Ssl\PublicKey; use Daanra\LaravelLetsEncrypt\Exceptions\DomainAlreadyExists; use Daanra\LaravelLetsEncrypt\Exceptions\InvalidDomainException; use Daanra\LaravelLetsEncrypt\Exceptions\InvalidKeyPairConfiguration; @@ -21,7 +21,7 @@ class LetsEncrypt { - /** @var \AcmePhp\Core\Http\SecureHttpClientFactory */ + /** @var \Daanra\LaravelLetsEncrypt\AcmePhp\Core\Http\SecureHttpClientFactory */ protected $factory; /** diff --git a/src/LetsEncryptServiceProvider.php b/src/LetsEncryptServiceProvider.php index a2a583d..1d51544 100644 --- a/src/LetsEncryptServiceProvider.php +++ b/src/LetsEncryptServiceProvider.php @@ -2,11 +2,11 @@ namespace Daanra\LaravelLetsEncrypt; -use AcmePhp\Core\Http\Base64SafeEncoder; -use AcmePhp\Core\Http\SecureHttpClientFactory; -use AcmePhp\Core\Http\ServerErrorHandler; -use AcmePhp\Ssl\Parser\KeyParser; -use AcmePhp\Ssl\Signer\DataSigner; +use Daanra\LaravelLetsEncrypt\AcmePhp\Core\Http\Base64SafeEncoder; +use Daanra\LaravelLetsEncrypt\AcmePhp\Core\Http\SecureHttpClientFactory; +use Daanra\LaravelLetsEncrypt\AcmePhp\Core\Http\ServerErrorHandler; +use Daanra\LaravelLetsEncrypt\AcmePhp\Ssl\Parser\KeyParser; +use Daanra\LaravelLetsEncrypt\AcmePhp\Ssl\Signer\DataSigner; use Daanra\LaravelLetsEncrypt\Commands\LetsEncryptGenerateCommand; use GuzzleHttp\Client as GuzzleHttpClient; use Illuminate\Support\ServiceProvider; diff --git a/src/PendingCertificate.php b/src/PendingCertificate.php index a62fdbf..5d0ad1d 100644 --- a/src/PendingCertificate.php +++ b/src/PendingCertificate.php @@ -83,8 +83,8 @@ public function create(): LetsEncryptCertificate $this->retryList ), ], $this->chain)) - ->dispatch($email, $this->tries, $this->retryAfter, $this->retryList) - ->delay($this->delay); + ->delay($this->delay) + ->dispatch($email, $this->tries, $this->retryAfter, $this->retryList); return $certificate; } diff --git a/tests/Models/LetsEncryptCertificateTest.php b/tests/Models/LetsEncryptCertificateTest.php index 69198e7..80f091c 100644 --- a/tests/Models/LetsEncryptCertificateTest.php +++ b/tests/Models/LetsEncryptCertificateTest.php @@ -66,12 +66,7 @@ public function test_renew() Queue::fake(); - $pendingDispatch = $certificate->renew(); - - $this->assertInstanceOf(PendingDispatch::class, $pendingDispatch); - - // Jobs are only pushed after the pending dispatch leaves memory. - $pendingDispatch->__destruct(); + $certificate->renew(); Queue::assertPushedWithChain(RegisterAccount::class, [ new RequestAuthorization($certificate), From 2e6e2cf2c0ed406189beb94bac0926b6e4b8bd16 Mon Sep 17 00:00:00 2001 From: Daanra Date: Sun, 22 May 2022 16:11:07 +0000 Subject: [PATCH 2/5] Fix styling --- src/AcmePhp/Core/AcmeClient.php | 16 ++++++++-------- src/AcmePhp/Core/Http/SecureHttpClient.php | 11 +++++++---- src/AcmePhp/Core/Http/ServerErrorHandler.php | 4 ++-- src/AcmePhp/Core/Protocol/CertificateOrder.php | 2 +- src/AcmePhp/Ssl/Certificate.php | 2 +- .../OpensslPrivateKeyGeneratorTrait.php | 4 ++-- src/AcmePhp/Ssl/Parser/CertificateParser.php | 10 +++++----- src/AcmePhp/Ssl/Parser/KeyParser.php | 4 ++-- src/AcmePhp/Ssl/PrivateKey.php | 4 ++-- src/AcmePhp/Ssl/PublicKey.php | 2 +- .../Ssl/Signer/CertificateRequestSigner.php | 4 ++-- src/AcmePhp/Ssl/Signer/DataSigner.php | 3 ++- tests/Models/LetsEncryptCertificateTest.php | 3 +-- 13 files changed, 36 insertions(+), 33 deletions(-) diff --git a/src/AcmePhp/Core/AcmeClient.php b/src/AcmePhp/Core/AcmeClient.php index e336031..720c9eb 100644 --- a/src/AcmePhp/Core/AcmeClient.php +++ b/src/AcmePhp/Core/AcmeClient.php @@ -81,7 +81,7 @@ public function __construct(SecureHttpClient $httpClient, $directoryUrl, Certifi */ public function getHttpClient() { - if (!$this->initializedHttpClient) { + if (! $this->initializedHttpClient) { $this->initializedHttpClient = $this->uninitializedHttpClient; $this->initializedHttpClient->setNonceEndpoint($this->getResourceUrl(ResourcesDirectory::NEW_NONCE)); @@ -150,7 +150,7 @@ function ($domain) { $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']) { + if (! isset($response['authorizations']) || ! $response['authorizations']) { throw new ChallengeNotSupportedException(); } @@ -194,7 +194,7 @@ public function challengeAuthorization(AuthorizationChallenge $challenge, $timeo } // Waiting loop - while (time() <= $endTime && (!isset($response['status']) || 'pending' === $response['status'] || 'processing' === $response['status'])) { + 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)); } @@ -202,7 +202,7 @@ public function challengeAuthorization(AuthorizationChallenge $challenge, $timeo if (isset($response['status']) && ('pending' === $response['status'] || 'processing' === $response['status'])) { throw new ChallengeTimedOutException($response); } - if (!isset($response['status']) || 'valid' !== $response['status']) { + if (! isset($response['status']) || 'valid' !== $response['status']) { throw new ChallengeFailedException($response); } @@ -244,7 +244,7 @@ public function finalizeOrder(CertificateOrder $order, CertificateRequest $csr, } // Waiting loop - while (time() <= $endTime && (!isset($response['status']) || \in_array($response['status'], ['pending', 'processing', 'ready']))) { + 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)); } @@ -267,7 +267,7 @@ public function finalizeOrder(CertificateOrder $order, CertificateRequest $csr, */ public function revokeCertificate(Certificate $certificate, RevocationReason $revocationReason = null) { - if (!$endpoint = $this->getResourceUrl(ResourcesDirectory::REVOKE_CERT)) { + if (! $endpoint = $this->getResourceUrl(ResourcesDirectory::REVOKE_CERT)) { throw new CertificateRevocationException('This ACME server does not support certificate revocation.'); } @@ -305,7 +305,7 @@ public function revokeCertificate(Certificate $certificate, RevocationReason $re */ public function getResourceUrl($resource) { - if (!$this->directory) { + if (! $this->directory) { $this->directory = new ResourcesDirectory( $this->getHttpClient()->request('GET', $this->directoryUrl) ); @@ -346,7 +346,7 @@ protected function requestResource($method, $resource, array $payload, $returnJs */ private function getResourceAccount() { - if (!$this->account) { + if (! $this->account) { $payload = [ 'onlyReturnExisting' => true, ]; diff --git a/src/AcmePhp/Core/Http/SecureHttpClient.php b/src/AcmePhp/Core/Http/SecureHttpClient.php index c9ae8c0..0603e54 100644 --- a/src/AcmePhp/Core/Http/SecureHttpClient.php +++ b/src/AcmePhp/Core/Http/SecureHttpClient.php @@ -130,11 +130,11 @@ private function getAlg() private function extractSignOptionFromJWSAlg($alg) { - if (!preg_match('/^([A-Z]+)(\d+)$/', $alg, $match)) { + 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])) { + if (! \defined('OPENSSL_ALGO_SHA'.$match[2])) { throw new AcmeCoreClientException(sprintf('The given "%s" algorithm is not supported', $alg)); } @@ -143,9 +143,11 @@ private function extractSignOptionFromJWSAlg($alg) 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)); @@ -235,7 +237,7 @@ public function signJwkPayload($endpoint, array $payload = null) */ private function signPayload(array $protected, array $payload = null) { - if (!isset($protected['alg'])) { + if (! isset($protected['alg'])) { throw new \InvalidArgumentException('The property "alg" is required in the protected array'); } $alg = $protected['alg']; @@ -320,6 +322,7 @@ public function request($method, $endpoint, array $data = [], $returnJson = true { $call = function () use ($method, $endpoint, $data) { $request = $this->createRequest($method, $endpoint, $data); + try { $this->lastResponse = $this->httpClient->send($request); } catch (\Exception $exception) { @@ -337,7 +340,7 @@ public function request($method, $endpoint, array $data = [], $returnJson = true $body = Utils::copyToString($this->lastResponse->getBody()); - if (!$returnJson) { + if (! $returnJson) { return $body; } diff --git a/src/AcmePhp/Core/Http/ServerErrorHandler.php b/src/AcmePhp/Core/Http/ServerErrorHandler.php index 7094b13..2a44eb4 100644 --- a/src/AcmePhp/Core/Http/ServerErrorHandler.php +++ b/src/AcmePhp/Core/Http/ServerErrorHandler.php @@ -108,14 +108,14 @@ public function createAcmeExceptionForResponse( $data = null; } - if (!$data || !isset($data['type'], $data['detail'])) { + 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])) { + if (! isset(self::$exceptions[$type])) { // Unknown type: not an ACME error response return $this->createDefaultExceptionForResponse($request, $response, $previous); } diff --git a/src/AcmePhp/Core/Protocol/CertificateOrder.php b/src/AcmePhp/Core/Protocol/CertificateOrder.php index e94052b..1f1600c 100644 --- a/src/AcmePhp/Core/Protocol/CertificateOrder.php +++ b/src/AcmePhp/Core/Protocol/CertificateOrder.php @@ -93,7 +93,7 @@ public function getAuthorizationsChallenges() */ public function getAuthorizationChallenges($domain) { - if (!isset($this->authorizationsChallenges[$domain])) { + if (! isset($this->authorizationsChallenges[$domain])) { throw new AcmeCoreClientException('The order does not contains any authorization challenge for the domain '.$domain); } diff --git a/src/AcmePhp/Ssl/Certificate.php b/src/AcmePhp/Ssl/Certificate.php index 3ab3487..d6cf376 100644 --- a/src/AcmePhp/Ssl/Certificate.php +++ b/src/AcmePhp/Ssl/Certificate.php @@ -76,7 +76,7 @@ public function getIssuerCertificate() */ public function getPublicKeyResource() { - if (!$resource = openssl_pkey_get_public($this->certificatePEM)) { + if (! $resource = openssl_pkey_get_public($this->certificatePEM)) { throw new CertificateFormatException(sprintf('Failed to convert certificate into public key resource: %s', openssl_error_string())); } diff --git a/src/AcmePhp/Ssl/Generator/OpensslPrivateKeyGeneratorTrait.php b/src/AcmePhp/Ssl/Generator/OpensslPrivateKeyGeneratorTrait.php index 22658f1..78cf6e3 100644 --- a/src/AcmePhp/Ssl/Generator/OpensslPrivateKeyGeneratorTrait.php +++ b/src/AcmePhp/Ssl/Generator/OpensslPrivateKeyGeneratorTrait.php @@ -21,10 +21,10 @@ private function generatePrivateKeyFromOpensslOptions(array $opensslOptions) { $resource = openssl_pkey_new($opensslOptions); - if (!$resource) { + if (! $resource) { throw new KeyGenerationException(sprintf('OpenSSL key creation failed during generation with error: %s', openssl_error_string())); } - if (!openssl_pkey_export($resource, $privateKey)) { + if (! openssl_pkey_export($resource, $privateKey)) { throw new KeyPairGenerationException(sprintf('OpenSSL key export failed during generation with error: %s', openssl_error_string())); } diff --git a/src/AcmePhp/Ssl/Parser/CertificateParser.php b/src/AcmePhp/Ssl/Parser/CertificateParser.php index e4246c8..c90541e 100644 --- a/src/AcmePhp/Ssl/Parser/CertificateParser.php +++ b/src/AcmePhp/Ssl/Parser/CertificateParser.php @@ -31,20 +31,20 @@ public function parse(Certificate $certificate) { $rawData = openssl_x509_parse($certificate->getPEM()); - if (!\is_array($rawData)) { + if (! \is_array($rawData)) { throw new CertificateParsingException(sprintf('Fail to parse certificate with error: %s', openssl_error_string())); } - if (!isset($rawData['subject']['CN'])) { + if (! isset($rawData['subject']['CN'])) { throw new CertificateParsingException('Missing expected key "subject.cn" in certificate'); } - if (!isset($rawData['serialNumber'])) { + if (! isset($rawData['serialNumber'])) { throw new CertificateParsingException('Missing expected key "serialNumber" in certificate'); } - if (!isset($rawData['validFrom_time_t'])) { + if (! isset($rawData['validFrom_time_t'])) { throw new CertificateParsingException('Missing expected key "validFrom_time_t" in certificate'); } - if (!isset($rawData['validTo_time_t'])) { + if (! isset($rawData['validTo_time_t'])) { throw new CertificateParsingException('Missing expected key "validTo_time_t" in certificate'); } diff --git a/src/AcmePhp/Ssl/Parser/KeyParser.php b/src/AcmePhp/Ssl/Parser/KeyParser.php index 8a7ddd2..1cbc73f 100644 --- a/src/AcmePhp/Ssl/Parser/KeyParser.php +++ b/src/AcmePhp/Ssl/Parser/KeyParser.php @@ -43,12 +43,12 @@ public function parse(Key $key) openssl_free_key($resource); } - if (!\is_array($rawData)) { + if (! \is_array($rawData)) { throw new KeyParsingException(sprintf('Fail to parse key with error: %s', openssl_error_string())); } foreach (['type', 'key', 'bits'] as $requiredKey) { - if (!isset($rawData[$requiredKey])) { + if (! isset($rawData[$requiredKey])) { throw new KeyParsingException(sprintf('Missing expected key "%s" in OpenSSL key', $requiredKey)); } } diff --git a/src/AcmePhp/Ssl/PrivateKey.php b/src/AcmePhp/Ssl/PrivateKey.php index ff42261..93e85d4 100644 --- a/src/AcmePhp/Ssl/PrivateKey.php +++ b/src/AcmePhp/Ssl/PrivateKey.php @@ -26,7 +26,7 @@ class PrivateKey extends Key */ public function getResource() { - if (!$resource = openssl_pkey_get_private($this->keyPEM)) { + if (! $resource = openssl_pkey_get_private($this->keyPEM)) { throw new KeyFormatException(sprintf('Failed to convert key into resource: %s', openssl_error_string())); } @@ -39,7 +39,7 @@ public function getResource() public function getPublicKey() { $resource = $this->getResource(); - if (!$details = openssl_pkey_get_details($resource)) { + if (! $details = openssl_pkey_get_details($resource)) { throw new KeyFormatException(sprintf('Failed to extract public key: %s', openssl_error_string())); } diff --git a/src/AcmePhp/Ssl/PublicKey.php b/src/AcmePhp/Ssl/PublicKey.php index 57bba60..c10d1c5 100644 --- a/src/AcmePhp/Ssl/PublicKey.php +++ b/src/AcmePhp/Ssl/PublicKey.php @@ -26,7 +26,7 @@ class PublicKey extends Key */ public function getResource() { - if (!$resource = openssl_pkey_get_public($this->keyPEM)) { + if (! $resource = openssl_pkey_get_public($this->keyPEM)) { throw new KeyFormatException(sprintf('Failed to convert key into resource: %s', openssl_error_string())); } diff --git a/src/AcmePhp/Ssl/Signer/CertificateRequestSigner.php b/src/AcmePhp/Ssl/Signer/CertificateRequestSigner.php index 5e9b335..899ebaf 100644 --- a/src/AcmePhp/Ssl/Signer/CertificateRequestSigner.php +++ b/src/AcmePhp/Ssl/Signer/CertificateRequestSigner.php @@ -31,7 +31,7 @@ public function signCertificateRequest(CertificateRequest $certificateRequest) { $csrObject = $this->createCsrWithSANsObject($certificateRequest); - if (!$csrObject || !openssl_csr_export($csrObject, $csrExport)) { + if (! $csrObject || ! openssl_csr_export($csrObject, $csrExport)) { throw new CSRSigningException(sprintf('OpenSSL CSR signing failed with error: %s', openssl_error_string())); } @@ -91,7 +91,7 @@ protected function createCsrWithSANsObject(CertificateRequest $certificateReques openssl_free_key($resource); } - if (!$csr) { + if (! $csr) { throw new CSRSigningException(sprintf('OpenSSL CSR signing failed with error: %s', openssl_error_string())); } diff --git a/src/AcmePhp/Ssl/Signer/DataSigner.php b/src/AcmePhp/Ssl/Signer/DataSigner.php index 4d2f8ff..f04c265 100644 --- a/src/AcmePhp/Ssl/Signer/DataSigner.php +++ b/src/AcmePhp/Ssl/Signer/DataSigner.php @@ -40,7 +40,7 @@ public function signData($data, PrivateKey $privateKey, $algorithm = OPENSSL_ALG Assert::oneOf($format, [self::FORMAT_ECDSA, self::FORMAT_DER], 'The format %s to sign request does not exists. Available format: %s'); $resource = $privateKey->getResource(); - if (!openssl_sign($data, $signature, $resource, $algorithm)) { + if (! openssl_sign($data, $signature, $resource, $algorithm)) { throw new DataSigningException(sprintf('OpenSSL data signing failed with error: %s', openssl_error_string())); } @@ -61,6 +61,7 @@ public function signData($data, PrivateKey $privateKey, $algorithm = OPENSSL_ALG case OPENSSL_ALGO_SHA512: return $this->DERtoECDSA($signature, 132); } + throw new DataSigningException('Unable to generate a ECDSA signature with the given algorithm'); default: throw new DataSigningException('The given format does exists'); diff --git a/tests/Models/LetsEncryptCertificateTest.php b/tests/Models/LetsEncryptCertificateTest.php index 80f091c..f70838f 100644 --- a/tests/Models/LetsEncryptCertificateTest.php +++ b/tests/Models/LetsEncryptCertificateTest.php @@ -10,7 +10,6 @@ use Daanra\LaravelLetsEncrypt\Jobs\RequestCertificate; use Daanra\LaravelLetsEncrypt\Models\LetsEncryptCertificate; use Daanra\LaravelLetsEncrypt\Tests\TestCase; -use Illuminate\Foundation\Bus\PendingDispatch; use Illuminate\Support\Facades\Queue; class LetsEncryptCertificateTest extends TestCase @@ -66,7 +65,7 @@ public function test_renew() Queue::fake(); - $certificate->renew(); + $certificate->renew(); Queue::assertPushedWithChain(RegisterAccount::class, [ new RequestAuthorization($certificate), From d058291609fdb7529ff06b12315f14738b988ab2 Mon Sep 17 00:00:00 2001 From: Daan Raatjes Date: Sun, 22 May 2022 18:17:21 +0200 Subject: [PATCH 3/5] Update test workflow --- .github/workflows/run-tests.yml | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/.github/workflows/run-tests.yml b/.github/workflows/run-tests.yml index 5555316..f78551c 100644 --- a/.github/workflows/run-tests.yml +++ b/.github/workflows/run-tests.yml @@ -9,12 +9,12 @@ jobs: fail-fast: true matrix: os: [ubuntu-latest] - php: [7.2] - laravel: [7.*] + php: [7.4] + laravel: [9.*] dependency-version: [prefer-stable] include: - - laravel: 7.* - testbench: 5.* + - laravel: 9.* + testbench: 7.* name: P${{ matrix.php }} - L${{ matrix.laravel }} - ${{ matrix.dependency-version }} - ${{ matrix.os }} From 84b8de8773d74cffc36858a8a8d26a870fe0a4e7 Mon Sep 17 00:00:00 2001 From: Daan Raatjes Date: Sun, 22 May 2022 18:19:31 +0200 Subject: [PATCH 4/5] Test on php 8 --- .github/workflows/run-tests.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/run-tests.yml b/.github/workflows/run-tests.yml index f78551c..ea00b34 100644 --- a/.github/workflows/run-tests.yml +++ b/.github/workflows/run-tests.yml @@ -9,7 +9,7 @@ jobs: fail-fast: true matrix: os: [ubuntu-latest] - php: [7.4] + php: [8.0] laravel: [9.*] dependency-version: [prefer-stable] include: From dbf1cb440b42b5caa70ec78c8af91fda1e391481 Mon Sep 17 00:00:00 2001 From: Daan Raatjes Date: Thu, 26 May 2022 21:23:36 +0200 Subject: [PATCH 5/5] Fix stream for --- src/AcmePhp/Core/Http/SecureHttpClient.php | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/AcmePhp/Core/Http/SecureHttpClient.php b/src/AcmePhp/Core/Http/SecureHttpClient.php index 0603e54..f48b617 100644 --- a/src/AcmePhp/Core/Http/SecureHttpClient.php +++ b/src/AcmePhp/Core/Http/SecureHttpClient.php @@ -425,7 +425,7 @@ private function createRequest($method, $endpoint, $data) if ('POST' === $method && \is_array($data)) { $request = $request->withHeader('Content-Type', 'application/jose+json'); - $request = $request->withBody(\GuzzleHttp\Psr7\stream_for(json_encode($data))); + $request = $request->withBody(Utils::streamFor(json_encode($data))); } return $request;