diff --git a/src/Client.php b/src/Client.php index 4a9fe7d..7dab1d2 100644 --- a/src/Client.php +++ b/src/Client.php @@ -60,6 +60,11 @@ class Client */ const VALIDATION_DNS = 'dns-01'; + /** + * DNS persist validation + */ + const VALIDATION_DNS_PERSIST = 'dns-persist-01'; + /** * @var string */ @@ -164,7 +169,8 @@ public function getOrder($id): Order $data['expires'], $data['identifiers'], $data['authorizations'], - $data['finalize'] + $data['finalize'], + $data['certificate'] ?? '' ); } @@ -195,7 +201,7 @@ public function createOrder(array $domains): Order foreach ($domains as $domain) { $identifiers[] = [ - 'type' => 'dns', + 'type' => 'dns', 'value' => $domain, ]; } @@ -239,7 +245,10 @@ public function authorize(Order $order): array $this->signPayloadKid(null, $authorizationURL) ); $data = json_decode((string)$response->getBody(), true); - $authorization = new Authorization($data['identifier']['value'], $data['expires'], $this->getDigest()); + $authorization = new Authorization($data['identifier']['value'], $data['expires'], $this->getDigest(), [ + 'wildcard' => $data['wildcard'] ?? false, + 'accountUri' => $this->getAccount()->getAccountURL() + ]); foreach ($data['challenges'] as $challengeData) { $challenge = new Challenge( @@ -247,7 +256,8 @@ public function authorize(Order $order): array $challengeData['type'], $challengeData['status'], $challengeData['url'], - $challengeData['token'] + $challengeData['token'] ?? null, + $challengeData['issuer-domain-names'] ?? [] ); $authorization->addChallenge($challenge); } @@ -270,6 +280,8 @@ public function selfTest(Authorization $authorization, $type = self::VALIDATION_ return $this->selfHttpTest($authorization, $maxAttempts); } elseif ($type == self::VALIDATION_DNS) { return $this->selfDNSTest($authorization, $maxAttempts); + } elseif ($type == self::VALIDATION_DNS_PERSIST) { + return $this->selfDnsPersistTest($authorization, $maxAttempts); } return false; } @@ -284,12 +296,22 @@ public function selfTest(Authorization $authorization, $type = self::VALIDATION_ */ public function validate(Challenge $challenge, int $maxAttempts = 15): bool { - $this->request( - $challenge->getUrl(), - $this->signPayloadKid([ - 'keyAuthorization' => $challenge->getToken() . '.' . $this->getDigest() - ], $challenge->getUrl()) - ); + if ($challenge->getType() == self::VALIDATION_DNS_PERSIST) { + $this->request( + $challenge->getUrl(), + $this->signPayloadKid( + (object)[], + $challenge->getUrl() + ) + ); + } else { + $this->request( + $challenge->getUrl(), + $this->signPayloadKid([ + 'keyAuthorization' => $challenge->getToken() . '.' . $this->getDigest() + ], $challenge->getUrl()) + ); + } $data = []; do { @@ -311,10 +333,11 @@ public function validate(Challenge $challenge, int $maxAttempts = 15): bool * Return a certificate * * @param Order $order + * @param int $maxAttempts * @return Certificate * @throws \Exception */ - public function getCertificate(Order $order): Certificate + public function getCertificate(Order $order, int $maxAttempts = 15): Certificate { $privateKey = Helper::getNewKey($this->getOption('key_length', 4096)); $csr = Helper::getCsr($order->getDomains(), $privateKey); @@ -329,11 +352,28 @@ public function getCertificate(Order $order): Certificate ); $data = json_decode((string)$response->getBody(), true); - $certificateResponse = $this->request( - $data['certificate'], - $this->signPayloadKid(null, $data['certificate']) - ); - $chain = $str = preg_replace('/^[ \t]*[\r\n]+/m', '', (string)$certificateResponse->getBody()); + $chain = null; + + if (isset($data['certificate'])) { + $chain = $this->downloadCertificate($data['certificate']); + } elseif ($data['status'] == 'processing') { + do { + sleep(5); + $order = $this->getOrder($order->getId()); + + if ($order->getStatus() == 'valid') { + $chain = $this->downloadCertificate($order->getCertificate()); + break; + } + + $maxAttempts--; + } while ($maxAttempts > 0); + } + + if (empty($chain)) { + throw new \LogicException("Could not get certificate"); + } + return new Certificate($privateKey, $csr, $chain); } @@ -359,7 +399,7 @@ public function getAccount(): Account $data = json_decode((string)$response->getBody(), true); $accountURL = $response->getHeaderLine('Location'); $date = (new \DateTime())->setTimestamp(strtotime($data['createdAt'])); - return new Account($data['contact'], $date, ($data['status'] == 'valid'), $accountURL); + return new Account($date, ($data['status'] == 'valid'), $accountURL); } /** @@ -389,8 +429,8 @@ protected function getHttpClient() protected function getSelfTestClient() { return new HttpClient([ - 'verify' => false, - 'timeout' => 10, + 'verify' => false, + 'timeout' => 10, 'connect_timeout' => 3, 'allow_redirects' => true, ]); @@ -458,6 +498,62 @@ protected function selfDNSTest(Authorization $authorization, $maxAttempts) return false; } + /** + * Self-test for dns-persist-01 validation using Cloudflare's DNS API. + * Verifies that a _validation-persist TXT record exists with the correct issuer domain and account URI. + * @param Authorization $authorization + * @param int $maxAttempts + * @return bool + */ + protected function selfDnsPersistTest(Authorization $authorization, int $maxAttempts) + { + $record = $authorization->getDnsPersistRecord(); + if ($record === false) { + return false; + } + + do { + $response = $this->getSelfTestDNSClient()->get( + '/dns-query', + [ + 'query' => [ + 'name' => $record->getName(), + 'type' => 'TXT' + ] + ] + ); + $data = json_decode((string)$response->getBody(), true); + if (isset($data['Answer'])) { + foreach ($data['Answer'] as $result) { + $txtData = trim($result['data'], "\""); + if ($this->txtRecordContainsAll($txtData, $record->getValue())) { + return true; + } + } + } + if ($maxAttempts > 1) { + sleep(ceil(45 / $maxAttempts)); + } + $maxAttempts--; + } while ($maxAttempts > 0); + + return false; + } + + /** + * Check if the actual TXT record contains all expected semicolon-separated segments. + * @param string $actual + * @param string $expected + * @return bool + */ + protected function txtRecordContainsAll(string $actual, string $expected): bool + { + $expectedSegments = array_map('trim', explode(';', $expected)); + $actualSegments = array_map('trim', explode(';', $actual)); + + return empty(array_diff($expectedSegments, $actualSegments)); + } + /** * Return the preconfigured client to call Cloudflare's DNS API * @return HttpClient @@ -465,9 +561,9 @@ protected function selfDNSTest(Authorization $authorization, $maxAttempts) protected function getSelfTestDNSClient() { return new HttpClient([ - 'base_uri' => 'https://cloudflare-dns.com', + 'base_uri' => 'https://cloudflare-dns.com', 'connect_timeout' => 10, - 'headers' => [ + 'headers' => [ 'Accept' => 'application/dns-json', ], ]); @@ -514,7 +610,7 @@ protected function tosAgree() $this->getUrl(self::DIRECTORY_NEW_ACCOUNT), $this->signPayloadJWK( [ - 'contact' => [ + 'contact' => [ 'mailto:' . $this->getOption('username'), ], 'termsOfServiceAgreed' => true, @@ -535,9 +631,9 @@ protected function getPath($path = null): string $userDirectory = preg_replace('/[^a-z0-9]+/', '-', strtolower($this->getOption('username'))); return $this->getOption( - 'basePath', - 'le' - ) . DIRECTORY_SEPARATOR . $userDirectory . ($path === null ? '' : DIRECTORY_SEPARATOR . $path); + 'basePath', + 'le' + ) . DIRECTORY_SEPARATOR . $userDirectory . ($path === null ? '' : DIRECTORY_SEPARATOR . $path); } /** @@ -593,7 +689,7 @@ protected function request($url, $payload = [], $method = 'POST'): ResponseInter { try { $response = $this->getHttpClient()->request($method, $url, [ - 'json' => $payload, + 'json' => $payload, 'headers' => [ 'Content-Type' => 'application/jose+json', ] @@ -653,9 +749,9 @@ protected function getAccountKey() protected function getJWKHeader(): array { return [ - 'e' => Helper::toSafeString(Helper::getKeyDetails($this->getAccountKey())['rsa']['e']), + 'e' => Helper::toSafeString(Helper::getKeyDetails($this->getAccountKey())['rsa']['e']), 'kty' => 'RSA', - 'n' => Helper::toSafeString(Helper::getKeyDetails($this->getAccountKey())['rsa']['n']), + 'n' => Helper::toSafeString(Helper::getKeyDetails($this->getAccountKey())['rsa']['n']), ]; } @@ -674,10 +770,10 @@ protected function getJWK($url): array $this->nonce = $response->getHeaderLine('replay-nonce'); } return [ - 'alg' => 'RS256', - 'jwk' => $this->getJWKHeader(), + 'alg' => 'RS256', + 'jwk' => $this->getJWKHeader(), 'nonce' => $this->nonce, - 'url' => $url + 'url' => $url ]; } @@ -694,10 +790,10 @@ protected function getKID($url): array $nonce = $response->getHeaderLine('replay-nonce'); return [ - "alg" => "RS256", - "kid" => $this->account->getAccountURL(), + "alg" => "RS256", + "kid" => $this->account->getAccountURL(), "nonce" => $nonce, - "url" => $url + "url" => $url ]; } @@ -711,7 +807,7 @@ protected function getKID($url): array */ protected function signPayloadJWK($payload, $url): array { - $payload = is_array($payload) ? str_replace('\\/', '/', json_encode($payload)) : ''; + $payload = (is_array($payload) || is_object($payload)) ? str_replace('\\/', '/', json_encode($payload)) : ''; $payload = Helper::toSafeString($payload); $protected = Helper::toSafeString(json_encode($this->getJWK($url))); @@ -723,7 +819,7 @@ protected function signPayloadJWK($payload, $url): array return [ 'protected' => $protected, - 'payload' => $payload, + 'payload' => $payload, 'signature' => Helper::toSafeString($signature), ]; } @@ -738,7 +834,7 @@ protected function signPayloadJWK($payload, $url): array */ protected function signPayloadKid($payload, $url): array { - $payload = is_array($payload) ? str_replace('\\/', '/', json_encode($payload)) : ''; + $payload = (is_array($payload) || is_object($payload)) ? str_replace('\\/', '/', json_encode($payload)) : ''; $payload = Helper::toSafeString($payload); $protected = Helper::toSafeString(json_encode($this->getKID($url))); @@ -749,8 +845,24 @@ protected function signPayloadKid($payload, $url): array return [ 'protected' => $protected, - 'payload' => $payload, + 'payload' => $payload, 'signature' => Helper::toSafeString($signature), ]; } + + /** + * @param string $certificateDownloadLink + * @return string + * @throws \Exception + */ + protected function downloadCertificate(string $certificateDownloadLink): string + { + $certificateResponse = $this->request( + $certificateDownloadLink, + $this->signPayloadKid(null, $certificateDownloadLink) + ); + + return preg_replace('/^[ \t]*[\r\n]+/m', '', (string)$certificateResponse->getBody()); + } + } diff --git a/src/Data/Account.php b/src/Data/Account.php index 99f8b66..7109f73 100644 --- a/src/Data/Account.php +++ b/src/Data/Account.php @@ -4,12 +4,6 @@ class Account { - - /** - * @var array - */ - protected $contact; - /** * @var string */ @@ -28,18 +22,15 @@ class Account /** * Account constructor. - * @param array $contact * @param \DateTime $createdAt * @param bool $isValid * @param string $accountURL */ public function __construct( - array $contact, \DateTime $createdAt, bool $isValid, string $accountURL ) { - $this->contact = $contact; $this->createdAt = $createdAt; $this->isValid = $isValid; $this->accountURL = $accountURL; @@ -73,12 +64,24 @@ public function getAccountURL(): string } /** + * @deprecated * Return contact data * @return array */ public function getContact(): array { - return $this->contact; + return []; + } + + /** + * @deprecated + * + * Return initial IP + * @return string + */ + public function getInitialIp(): string + { + return ''; } /** diff --git a/src/Data/Authorization.php b/src/Data/Authorization.php index 5864ab6..e397bbc 100644 --- a/src/Data/Authorization.php +++ b/src/Data/Authorization.php @@ -28,18 +28,30 @@ class Authorization */ protected $digest; + /** + * @var string|null + */ + protected $accountUri; + + /** + * @var bool + */ + protected $isWildcard; + /** * Authorization constructor. * @param string $domain * @param string $expires * @param string $digest - * @throws \Exception + * @param array $options */ - public function __construct(string $domain, string $expires, string $digest) + public function __construct(string $domain, string $expires, string $digest, array $options = []) { $this->domain = $domain; $this->expires = (new \DateTime())->setTimestamp(strtotime($expires)); $this->digest = $digest; + $this->accountUri = $options['accountUri'] ?? null; + $this->isWildcard = $options['wildcard'] ?? false; } /** @@ -60,6 +72,16 @@ public function getDomain(): string return $this->domain; } + /** + * Return the order is wildcard or not + * + * @return bool + */ + public function isWildcard(): bool + { + return $this->isWildcard; + } + /** * Return the expiry of the authorization @@ -108,6 +130,21 @@ public function getDnsChallenge() return false; } + /** + * Return the DNS persist challenge + * @return Challenge|bool + */ + public function getDnsPersistChallenge() + { + foreach ($this->getChallenges() as $challenge) { + if ($challenge->getType() == Client::VALIDATION_DNS_PERSIST) { + return $challenge; + } + } + + return false; + } + /** * Return File object for the given challenge * @return File|bool @@ -137,4 +174,30 @@ public function getTxtRecord() return false; } + + /** + * Returns the DNS persist record object for dns-persist-01 validation + * + * @return Record|bool + */ + public function getDnsPersistRecord() + { + $challenge = $this->getDnsPersistChallenge(); + if ($challenge === false) { + return false; + } + + $issuerDomainNames = $challenge->getIssuerDomainNames(); + if (empty($issuerDomainNames) || $this->accountUri === null) { + return false; + } + + $value = $issuerDomainNames[0] . '; accounturi=' . $this->accountUri; + + if ($this->isWildcard) { + $value .= '; policy=wildcard'; + } + + return new Record('_validation-persist.' . $this->getDomain(), $value); + } } diff --git a/src/Data/Challenge.php b/src/Data/Challenge.php index 95493b3..64cc6ab 100644 --- a/src/Data/Challenge.php +++ b/src/Data/Challenge.php @@ -26,25 +26,32 @@ class Challenge protected $url; /** - * @var string + * @var string|null */ protected $token; + /** + * @var array + */ + protected $issuerDomainNames; + /** * Challenge constructor. * @param string $authorizationURL * @param string $type * @param string $status * @param string $url - * @param string $token + * @param string|null $token + * @param array $issuerDomainNames */ - public function __construct(string $authorizationURL, string $type, string $status, string $url, string $token) + public function __construct(string $authorizationURL, string $type, string $status, string $url, ?string $token = null, array $issuerDomainNames = []) { $this->authorizationURL = $authorizationURL; $this->type = $type; $this->status = $status; $this->url = $url; $this->token = $token; + $this->issuerDomainNames = $issuerDomainNames; } /** @@ -67,9 +74,9 @@ public function getType(): string /** * Returns the token - * @return string + * @return string|null */ - public function getToken(): string + public function getToken(): ?string { return $this->token; } @@ -91,4 +98,13 @@ public function getAuthorizationURL(): string { return $this->authorizationURL; } + + /** + * Returns the issuer domain names (used by dns-persist-01) + * @return array + */ + public function getIssuerDomainNames(): array + { + return $this->issuerDomainNames; + } } diff --git a/src/Data/Order.php b/src/Data/Order.php index f1f85f5..5f96646 100644 --- a/src/Data/Order.php +++ b/src/Data/Order.php @@ -41,6 +41,11 @@ class Order */ protected $domains; + /** + * @var string + */ + protected $certificate = null; + /** * Order constructor. * @param array $domains @@ -50,6 +55,7 @@ class Order * @param array $identifiers * @param array $authorizations * @param string $finalizeURL + * @param string $certificate * @throws \Exception */ public function __construct( @@ -59,7 +65,8 @@ public function __construct( string $expiresAt, array $identifiers, array $authorizations, - string $finalizeURL + string $finalizeURL, + string $certificate = '' ) { //Handle the microtime date format if (strpos($expiresAt, '.') !== false) { @@ -72,6 +79,7 @@ public function __construct( $this->identifiers = $identifiers; $this->authorizations = $authorizations; $this->finalizeURL = $finalizeURL; + $this->certificate = $certificate; } @@ -146,4 +154,13 @@ public function getDomains(): array { return $this->domains; } + + /** + * Returns domains for the order + * @return null|string + */ + public function getCertificate(): string + { + return $this->certificate; + } }