* * For the full copyright and license information, please view the LICENSE * file that was distributed with this source code. */ namespace AcmePhp\Core; use AcmePhp\Core\Exception\AcmeCoreClientException; use AcmePhp\Core\Exception\AcmeCoreServerException; use AcmePhp\Core\Exception\Protocol\CertificateRequestFailedException; use AcmePhp\Core\Exception\Protocol\CertificateRevocationException; use AcmePhp\Core\Exception\Protocol\ChallengeFailedException; use AcmePhp\Core\Exception\Protocol\ChallengeNotSupportedException; use AcmePhp\Core\Exception\Protocol\ChallengeTimedOutException; use AcmePhp\Core\Http\SecureHttpClient; use AcmePhp\Core\Protocol\AuthorizationChallenge; use AcmePhp\Core\Protocol\CertificateOrder; use AcmePhp\Core\Protocol\ExternalAccount; use AcmePhp\Core\Protocol\ResourcesDirectory; use AcmePhp\Core\Protocol\RevocationReason; use AcmePhp\Ssl\Certificate; use AcmePhp\Ssl\CertificateRequest; use AcmePhp\Ssl\CertificateResponse; use AcmePhp\Ssl\Signer\CertificateRequestSigner; use GuzzleHttp\Psr7\Utils; use Webmozart\Assert\Assert; /** * ACME protocol client implementation. * * @author Titouan Galopin */ class AcmeClient implements AcmeClientInterface { /** * @var SecureHttpClient */ private $uninitializedHttpClient; /** * @var SecureHttpClient */ private $initializedHttpClient; /** * @var CertificateRequestSigner */ private $csrSigner; /** * @var string */ private $directoryUrl; /** * @var ResourcesDirectory */ private $directory; /** * @var string */ private $account; public function __construct(SecureHttpClient $httpClient, string $directoryUrl, CertificateRequestSigner $csrSigner = null) { $this->uninitializedHttpClient = $httpClient; $this->directoryUrl = $directoryUrl; $this->csrSigner = $csrSigner ?: new CertificateRequestSigner(); } /** * {@inheritdoc} */ public function registerAccount(string $email = null, ExternalAccount $externalAccount = null): array { $client = $this->getHttpClient(); $payload = [ 'termsOfServiceAgreed' => true, 'contact' => [], ]; if ($email) { $payload['contact'][] = 'mailto:'.$email; } if ($externalAccount) { $payload['externalAccountBinding'] = $client->createExternalAccountPayload( $externalAccount, $this->getResourceUrl(ResourcesDirectory::NEW_ACCOUNT) ); } $this->requestResource('POST', ResourcesDirectory::NEW_ACCOUNT, $payload); $account = $this->getResourceAccount(); return $client->request('POST', $account, $client->signKidPayload($account, $account, null)); } /** * {@inheritdoc} */ public function requestOrder(array $domains): CertificateOrder { Assert::allStringNotEmpty($domains, 'requestOrder::$domains expected a list of strings. Got: %s'); $payload = [ 'identifiers' => array_map( static function ($domain) { return [ 'type' => 'dns', 'value' => $domain, ]; }, array_values($domains) ), ]; $client = $this->getHttpClient(); $resourceUrl = $this->getResourceUrl(ResourcesDirectory::NEW_ORDER); $response = $client->request('POST', $resourceUrl, $client->signKidPayload($resourceUrl, $this->getResourceAccount(), $payload)); if (!isset($response['authorizations']) || !$response['authorizations']) { throw new ChallengeNotSupportedException(); } $authorizationsChallenges = []; $orderEndpoint = $client->getLastLocation(); foreach ($response['authorizations'] as $authorizationEndpoint) { $authorizationsResponse = $client->request('POST', $authorizationEndpoint, $client->signKidPayload($authorizationEndpoint, $this->getResourceAccount(), null)); $domain = (empty($authorizationsResponse['wildcard']) ? '' : '*.').$authorizationsResponse['identifier']['value']; foreach ($authorizationsResponse['challenges'] as $challenge) { $authorizationsChallenges[$domain][] = $this->createAuthorizationChallenge($authorizationsResponse['identifier']['value'], $challenge); } } return new CertificateOrder($authorizationsChallenges, $orderEndpoint, $response['status']); } /** * {@inheritdoc} */ public function reloadOrder(CertificateOrder $order): CertificateOrder { $client = $this->getHttpClient(); $orderEndpoint = $order->getOrderEndpoint(); $response = $client->request('POST', $orderEndpoint, $client->signKidPayload($orderEndpoint, $this->getResourceAccount(), null)); if (!isset($response['authorizations']) || !$response['authorizations']) { throw new ChallengeNotSupportedException(); } $authorizationsChallenges = []; foreach ($response['authorizations'] as $authorizationEndpoint) { $authorizationsResponse = $client->request('POST', $authorizationEndpoint, $client->signKidPayload($authorizationEndpoint, $this->getResourceAccount(), null)); $domain = (empty($authorizationsResponse['wildcard']) ? '' : '*.').$authorizationsResponse['identifier']['value']; foreach ($authorizationsResponse['challenges'] as $challenge) { $authorizationsChallenges[$domain][] = $this->createAuthorizationChallenge($authorizationsResponse['identifier']['value'], $challenge); } } return new CertificateOrder($authorizationsChallenges, $orderEndpoint, $response['status']); } /** * {@inheritdoc} */ public function finalizeOrder(CertificateOrder $order, CertificateRequest $csr, int $timeout = 180, bool $returnAlternateCertificateIfAvailable = false): CertificateResponse { $endTime = time() + $timeout; $client = $this->getHttpClient(); $orderEndpoint = $order->getOrderEndpoint(); $response = $client->request('POST', $orderEndpoint, $client->signKidPayload($orderEndpoint, $this->getResourceAccount(), null)); if (\in_array($response['status'], ['pending', 'processing', 'ready'])) { $humanText = ['-----BEGIN CERTIFICATE REQUEST-----', '-----END CERTIFICATE REQUEST-----']; $csrContent = $this->csrSigner->signCertificateRequest($csr); $csrContent = trim(str_replace($humanText, '', $csrContent)); $csrContent = trim($client->getBase64Encoder()->encode(base64_decode($csrContent))); $response = $client->request('POST', $response['finalize'], $client->signKidPayload($response['finalize'], $this->getResourceAccount(), ['csr' => $csrContent])); } // Waiting loop while (time() <= $endTime && (!isset($response['status']) || \in_array($response['status'], ['pending', 'processing', 'ready']))) { sleep(1); $response = $client->request('POST', $orderEndpoint, $client->signKidPayload($orderEndpoint, $this->getResourceAccount(), null)); } if ('valid' !== $response['status']) { throw new CertificateRequestFailedException('The order has not been validated'); } $response = $client->rawRequest('POST', $response['certificate'], $client->signKidPayload($response['certificate'], $this->getResourceAccount(), null)); $responseHeaders = $response->getHeaders(); if ($returnAlternateCertificateIfAvailable && isset($responseHeaders['Link'][1])) { $matches = []; preg_match('/<(http.*)>;rel="alternate"/', $responseHeaders['Link'][1], $matches); // If response headers include a valid alternate certificate link, return that certificate instead if (isset($matches[1])) { return $this->createCertificateResponse( $csr, $client->request('POST', $matches[1], $client->signKidPayload($matches[1], $this->getResourceAccount(), null), false) ); } } return $this->createCertificateResponse($csr, Utils::copyToString($response->getBody())); } /** * {@inheritdoc} */ public function requestAuthorization(string $domain): array { $order = $this->requestOrder([$domain]); try { return $order->getAuthorizationChallenges($domain); } catch (AcmeCoreClientException $e) { throw new ChallengeNotSupportedException(); } } /** * {@inheritdoc} */ public function reloadAuthorization(AuthorizationChallenge $challenge): AuthorizationChallenge { $client = $this->getHttpClient(); $challengeUrl = $challenge->getUrl(); $response = (array) $client->request('POST', $challengeUrl, $client->signKidPayload($challengeUrl, $this->getResourceAccount(), null)); return $this->createAuthorizationChallenge($challenge->getDomain(), $response); } /** * {@inheritdoc} */ public function challengeAuthorization(AuthorizationChallenge $challenge, int $timeout = 180): array { $endTime = time() + $timeout; $client = $this->getHttpClient(); $challengeUrl = $challenge->getUrl(); $response = (array) $client->request('POST', $challengeUrl, $client->signKidPayload($challengeUrl, $this->getResourceAccount(), null)); if ('pending' === $response['status'] || 'processing' === $response['status']) { $response = (array) $client->request('POST', $challengeUrl, $client->signKidPayload($challengeUrl, $this->getResourceAccount(), [])); } // Waiting loop while (time() <= $endTime && (!isset($response['status']) || 'pending' === $response['status'] || 'processing' === $response['status'])) { sleep(1); $response = (array) $client->request('POST', $challengeUrl, $client->signKidPayload($challengeUrl, $this->getResourceAccount(), null)); } if (isset($response['status']) && ('pending' === $response['status'] || 'processing' === $response['status'])) { throw new ChallengeTimedOutException($response); } if (!isset($response['status']) || 'valid' !== $response['status']) { throw new ChallengeFailedException($response); } return $response; } /** * {@inheritdoc} */ public function requestCertificate(string $domain, CertificateRequest $csr, int $timeout = 180, bool $returnAlternateCertificateIfAvailable = false): CertificateResponse { $order = $this->requestOrder(array_unique(array_merge([$domain], $csr->getDistinguishedName()->getSubjectAlternativeNames()))); return $this->finalizeOrder($order, $csr, $timeout, $returnAlternateCertificateIfAvailable); } /** * {@inheritdoc} */ public function revokeCertificate(Certificate $certificate, RevocationReason $revocationReason = null) { if (!$endpoint = $this->getResourceUrl(ResourcesDirectory::REVOKE_CERT)) { throw new CertificateRevocationException('This ACME server does not support certificate revocation.'); } if (null === $revocationReason) { $revocationReason = RevocationReason::createDefaultReason(); } openssl_x509_export(openssl_x509_read($certificate->getPEM()), $formattedPem); $formattedPem = str_ireplace('-----BEGIN CERTIFICATE-----', '', $formattedPem); $formattedPem = str_ireplace('-----END CERTIFICATE-----', '', $formattedPem); $client = $this->getHttpClient(); $formattedPem = $client->getBase64Encoder()->encode(base64_decode(trim($formattedPem))); try { $client->request( 'POST', $endpoint, $client->signKidPayload($endpoint, $this->getResourceAccount(), ['certificate' => $formattedPem, 'reason' => $revocationReason->getReasonType()]), false ); } catch (AcmeCoreServerException $e) { throw new CertificateRevocationException($e->getMessage(), $e); } catch (AcmeCoreClientException $e) { throw new CertificateRevocationException($e->getMessage(), $e); } } /** * Find a resource URL from the Certificate Authority. */ public function getResourceUrl(string $resource): string { if (!$this->directory) { $this->directory = new ResourcesDirectory( $this->getHttpClient()->request('GET', $this->directoryUrl) ); } return $this->directory->getResourceUrl($resource); } /** * Request a resource (URL is found using ACME server directory). * * @throws AcmeCoreServerException when the ACME server returns an error HTTP status code * @throws AcmeCoreClientException when an error occured during response parsing * * @return array|string */ protected function requestResource(string $method, string $resource, array $payload, bool $returnJson = true) { $client = $this->getHttpClient(); $endpoint = $this->getResourceUrl($resource); return $client->request( $method, $endpoint, $client->signJwkPayload($endpoint, $payload), $returnJson ); } private function createCertificateResponse(CertificateRequest $csr, string $certificate): CertificateResponse { $certificateHeader = '-----BEGIN CERTIFICATE-----'; $certificatesChain = null; foreach (array_reverse(explode($certificateHeader, $certificate)) as $pem) { if ('' !== \trim($pem)) { $certificatesChain = new Certificate($certificateHeader.$pem, $certificatesChain); } } return new CertificateResponse($csr, $certificatesChain); } private function getResourceAccount(): string { if (!$this->account) { $payload = [ 'onlyReturnExisting' => true, ]; $this->requestResource('POST', ResourcesDirectory::NEW_ACCOUNT, $payload); $this->account = $this->getHttpClient()->getLastLocation(); } return $this->account; } private function createAuthorizationChallenge($domain, array $response): AuthorizationChallenge { $base64encoder = $this->getHttpClient()->getBase64Encoder(); return new AuthorizationChallenge( $domain, $response['status'], $response['type'], $response['url'], $response['token'], $response['token'].'.'.$base64encoder->encode($this->getHttpClient()->getJWKThumbprint()) ); } private function getHttpClient(): SecureHttpClient { if (!$this->initializedHttpClient) { $this->initializedHttpClient = $this->uninitializedHttpClient; $this->initializedHttpClient->setNonceEndpoint($this->getResourceUrl(ResourcesDirectory::NEW_NONCE)); } return $this->initializedHttpClient; } }