From 04cb3b870be47cb19fd75cbe5370c4e1c87f0f7b Mon Sep 17 00:00:00 2001 From: Tristan Date: Fri, 10 Nov 2023 11:27:40 +0100 Subject: [PATCH 01/15] Add a 'base' LetsEncryptException runtime exception for easier catching --- src/Endpoints/Account.php | 8 ++++---- src/Endpoints/Certificate.php | 8 ++++---- src/Endpoints/Order.php | 10 +++++----- src/Exceptions/DomainValidationException.php | 4 +--- src/Exceptions/LetsEncryptClientException.php | 9 +++++++++ src/Support/CryptRSA.php | 4 ++-- src/Support/JsonWebKey.php | 6 ++++++ src/Support/OpenSsl.php | 6 +++--- 8 files changed, 34 insertions(+), 21 deletions(-) create mode 100644 src/Exceptions/LetsEncryptClientException.php diff --git a/src/Endpoints/Account.php b/src/Endpoints/Account.php index df9db0b..f0c4dd7 100644 --- a/src/Endpoints/Account.php +++ b/src/Endpoints/Account.php @@ -4,8 +4,8 @@ use Rogierw\RwAcme\DTO\AccountData; use Rogierw\RwAcme\Support\CryptRSA; +use Rogierw\RwAcme\Exceptions\LetsEncryptClientException; use Rogierw\RwAcme\Support\JsonWebSignature; -use RuntimeException; class Account extends Endpoint { @@ -50,13 +50,13 @@ public function create(): AccountData return AccountData::fromResponse($response); } - throw new RuntimeException('Creating account failed.'); + throw new LetsEncryptClientException('Creating account failed.'); } public function get(): AccountData { if (!$this->exists()) { - throw new RuntimeException('Account keys not found.'); + throw new LetsEncryptClientException('Account keys not found.'); } $payload = [ @@ -75,7 +75,7 @@ public function get(): AccountData $response = $this->client->getHttpClient()->post($newAccountUrl, $signedPayload); if ($response->getHttpResponseCode() === 400) { - throw new RuntimeException($response->getBody()); + throw new LetsEncryptClientException($response->getBody()); } return AccountData::fromResponse($response); diff --git a/src/Endpoints/Certificate.php b/src/Endpoints/Certificate.php index 7d15561..7b5003b 100644 --- a/src/Endpoints/Certificate.php +++ b/src/Endpoints/Certificate.php @@ -4,8 +4,8 @@ use Rogierw\RwAcme\DTO\CertificateBundleData; use Rogierw\RwAcme\DTO\OrderData; +use Rogierw\RwAcme\Exceptions\LetsEncryptClientException; use Rogierw\RwAcme\Support\Base64; -use RuntimeException; class Certificate extends Endpoint { @@ -16,7 +16,7 @@ public function getBundle(OrderData $orderData): CertificateBundleData $response = $this->client->getHttpClient()->post($orderData->certificateUrl, $signedPayload); if ($response->getHttpResponseCode() !== 200) { - throw new RuntimeException('Failed to fetch certificate.'); + throw new LetsEncryptClientException('Failed to fetch certificate.'); } return CertificateBundleData::fromResponse($response); @@ -25,11 +25,11 @@ public function getBundle(OrderData $orderData): CertificateBundleData public function revoke(string $pem, int $reason = 0): bool { if (($data = openssl_x509_read($pem)) === false) { - throw new RuntimeException('Could not parse the certificate.'); + throw new LetsEncryptClientException('Could not parse the certificate.'); } if (openssl_x509_export($data, $certificate) === false) { - throw new RuntimeException('Could not export the certificate.'); + throw new LetsEncryptClientException('Could not export the certificate.'); } preg_match('~-----BEGIN\sCERTIFICATE-----(.*)-----END\sCERTIFICATE-----~s', $certificate, $matches); diff --git a/src/Endpoints/Order.php b/src/Endpoints/Order.php index d1c463e..225623a 100644 --- a/src/Endpoints/Order.php +++ b/src/Endpoints/Order.php @@ -4,8 +4,8 @@ use Rogierw\RwAcme\DTO\AccountData; use Rogierw\RwAcme\DTO\OrderData; +use Rogierw\RwAcme\Exceptions\LetsEncryptClientException; use Rogierw\RwAcme\Support\Base64; -use RuntimeException; class Order extends Endpoint { @@ -14,7 +14,7 @@ public function new(AccountData $accountData, array $domains): OrderData $identifiers = []; foreach ($domains as $domain) { if (preg_match_all('~(\*\.)~', $domain) > 1) { - throw new RuntimeException('Cannot create orders with multiple wildcards in one domain.'); + throw new LetsEncryptClientException('Cannot create orders with multiple wildcards in one domain.'); } $identifiers[] = [ @@ -40,7 +40,7 @@ public function new(AccountData $accountData, array $domains): OrderData $response = $this->client->getHttpClient()->post($newOrderUrl, $keyId); if ($response->getHttpResponseCode() !== 201) { - throw new RuntimeException('Creating new order failed; bad response code.'); + throw new LetsEncryptClientException('Creating new order failed; bad response code.'); } return OrderData::fromResponse($response, $accountData->url); @@ -59,13 +59,13 @@ public function get(string $id): OrderData $response = $this->client->getHttpClient()->get($orderUrl); if ($response->getHttpResponseCode() === 500) { - throw new RuntimeException($response->getBody()); + throw new LetsEncryptClientException($response->getBody()); } if ($response->getHttpResponseCode() === 404) { $this->client->logger('error', $response->getBody()); - throw new RuntimeException('Order not found.'); + throw new LetsEncryptClientException('Order not found.'); } return OrderData::fromResponse($response, $account->url); diff --git a/src/Exceptions/DomainValidationException.php b/src/Exceptions/DomainValidationException.php index 2d1361f..1d19baa 100644 --- a/src/Exceptions/DomainValidationException.php +++ b/src/Exceptions/DomainValidationException.php @@ -2,9 +2,7 @@ namespace Rogierw\RwAcme\Exceptions; -use Exception; - -class DomainValidationException extends Exception +class DomainValidationException extends LetsEncryptClientException { public static function localHttpChallengeTestFailed(string $domain, string $code): self { diff --git a/src/Exceptions/LetsEncryptClientException.php b/src/Exceptions/LetsEncryptClientException.php new file mode 100644 index 0000000..e445eca --- /dev/null +++ b/src/Exceptions/LetsEncryptClientException.php @@ -0,0 +1,9 @@ + Date: Fri, 10 Nov 2023 13:48:53 +0100 Subject: [PATCH 02/15] [breaking] Add KeyStorageInterface to allow custom implementations --- composer.json | 8 +- phpunit.xml | 18 ++++ src/Api.php | 53 ++++++----- src/Endpoints/Account.php | 33 +------ src/Endpoints/Endpoint.php | 8 +- src/Interfaces/KeyStorageInterface.php | 12 +++ src/Support/CryptRSA.php | 11 ++- src/Support/JsonWebKey.php | 10 ++- src/Support/JsonWebSignature.php | 23 ++--- src/Support/KeyId.php | 20 +++-- src/Support/KeyStorage/FileKeyStorage.php | 105 ++++++++++++++++++++++ tests/Unit/ApiTest.php | 15 ++++ 12 files changed, 236 insertions(+), 80 deletions(-) create mode 100644 phpunit.xml create mode 100644 src/Interfaces/KeyStorageInterface.php create mode 100644 src/Support/KeyStorage/FileKeyStorage.php create mode 100644 tests/Unit/ApiTest.php diff --git a/composer.json b/composer.json index 8588739..9bf445e 100644 --- a/composer.json +++ b/composer.json @@ -21,7 +21,8 @@ "spatie/laravel-data": "^3.5" }, "require-dev": { - "larapack/dd": "^1.0" + "larapack/dd": "^1.0", + "pestphp/pest": "^2.24" }, "autoload": { "psr-4": { @@ -32,6 +33,9 @@ ] }, "config": { - "sort-packages": true + "sort-packages": true, + "allow-plugins": { + "pestphp/pest-plugin": true + } } } diff --git a/phpunit.xml b/phpunit.xml new file mode 100644 index 0000000..7d0904f --- /dev/null +++ b/phpunit.xml @@ -0,0 +1,18 @@ + + + + + ./tests + + + + + ./app + ./src + + + diff --git a/src/Api.php b/src/Api.php index 612572c..19d2488 100644 --- a/src/Api.php +++ b/src/Api.php @@ -10,24 +10,48 @@ use Rogierw\RwAcme\Endpoints\Nonce; use Rogierw\RwAcme\Endpoints\Order; use Rogierw\RwAcme\Http\Client; -use Rogierw\RwAcme\Support\Str; +use Rogierw\RwAcme\Interfaces\KeyStorageInterface; +use Rogierw\RwAcme\Support\KeyStorage\FileKeyStorage; class Api { - const PRODUCTION_URL = 'https://acme-v02.api.letsencrypt.org'; - const STAGING_URL = 'https://acme-staging-v02.api.letsencrypt.org'; + private const PRODUCTION_URL = 'https://acme-v02.api.letsencrypt.org'; + private const STAGING_URL = 'https://acme-staging-v02.api.letsencrypt.org'; private string $baseUrl; private Client $httpClient; + public KeyStorageInterface $keyStorage; public function __construct( - private readonly string $accountEmail, - private string $accountKeysPath, - bool $staging = false, - private ?LoggerInterface $logger = null - ) { + KeyStorageInterface|string $keyStorage, + private readonly ?string $accountEmail = null, + bool $staging = false, + private ?LoggerInterface $logger = null + ) + { $this->baseUrl = $staging ? self::STAGING_URL : self::PRODUCTION_URL; $this->httpClient = new Client(); + + // If a string is passed, create a FileKeyStorage instance with the string as the path. + if (is_string($keyStorage)) { + $this->keyStorage = new FileKeyStorage($keyStorage); + } else { + $this->keyStorage = $keyStorage; + } + + if ($this->accountEmail !== null) { + $this->useAccount($this->accountEmail); + } + } + + public function useAccount(string $accountName): self + { + $alphaNumAccountName = preg_replace('/[^a-zA-Z0-9\-]/', '_', $accountName); + $shortHash = substr(hash('sha256', $accountName), 0, 16); + // Set/change the account name to allow for multiple accounts to be used. + $this->keyStorage->setAccountName($shortHash.'_'.$alphaNumAccountName); + + return $this; } public function directory(): Directory @@ -65,19 +89,6 @@ public function getAccountEmail(): string return $this->accountEmail; } - public function getAccountKeysPath(): string - { - if (!Str::endsWith($this->accountKeysPath, '/')) { - $this->accountKeysPath .= '/'; - } - - if (!is_dir($this->accountKeysPath)) { - mkdir($this->accountKeysPath, 0755, true); - } - - return $this->accountKeysPath; - } - public function getBaseUrl(): string { return $this->baseUrl; diff --git a/src/Endpoints/Account.php b/src/Endpoints/Account.php index f0c4dd7..e7f67d0 100644 --- a/src/Endpoints/Account.php +++ b/src/Endpoints/Account.php @@ -3,7 +3,6 @@ namespace Rogierw\RwAcme\Endpoints; use Rogierw\RwAcme\DTO\AccountData; -use Rogierw\RwAcme\Support\CryptRSA; use Rogierw\RwAcme\Exceptions\LetsEncryptClientException; use Rogierw\RwAcme\Support\JsonWebSignature; @@ -11,21 +10,12 @@ class Account extends Endpoint { public function exists(): bool { - if (!is_dir($this->client->getAccountKeysPath())) { - return false; - } - - if (is_file($this->client->getAccountKeysPath() . 'private.pem') - && is_file($this->client->getAccountKeysPath() . 'public.pem')) { - return true; - } - - return false; + return $this->client->keyStorage->exists(); } public function create(): AccountData { - $this->initAccountDirectory(); + $this->client->keyStorage->generateNewKeys(); $payload = [ 'contact' => $this->buildContactPayload($this->client->getAccountEmail()), @@ -38,7 +28,7 @@ public function create(): AccountData $payload, $newAccountUrl, $this->client->nonce()->getNew(), - $this->client->getAccountKeysPath() + $this->client->keyStorage->getPrivateKey(), ); $response = $this->client->getHttpClient()->post( @@ -69,7 +59,7 @@ public function get(): AccountData $payload, $newAccountUrl, $this->client->nonce()->getNew(), - $this->client->getAccountKeysPath() + $this->client->keyStorage->getPrivateKey(), ); $response = $this->client->getHttpClient()->post($newAccountUrl, $signedPayload); @@ -81,21 +71,6 @@ public function get(): AccountData return AccountData::fromResponse($response); } - private function initAccountDirectory(string $keyType = 'RSA'): void - { - if ($keyType !== 'RSA') { - throw new RuntimeException('Key type is not supported.'); - } - - if (!is_dir($this->client->getAccountKeysPath())) { - mkdir($this->client->getAccountKeysPath()); - } - - if ($keyType === 'RSA') { - CryptRSA::generate($this->client->getAccountKeysPath()); - } - } - private function buildContactPayload(string $email): array { return [ diff --git a/src/Endpoints/Endpoint.php b/src/Endpoints/Endpoint.php index 7e8fd3a..265eed4 100644 --- a/src/Endpoints/Endpoint.php +++ b/src/Endpoints/Endpoint.php @@ -11,11 +11,11 @@ public function __construct(protected Api $client) { } - protected function createKeyId(string $acountUrl, string $url, ?array $payload = null): array + protected function createKeyId(string $accountUrl, string $url, ?array $payload = null): array { return KeyId::generate( - $this->client->getAccountKeysPath(), - $acountUrl, + $this->client->keyStorage->getPrivateKey(), + $accountUrl, $url, $this->client->nonce()->getNew(), $payload @@ -24,6 +24,6 @@ protected function createKeyId(string $acountUrl, string $url, ?array $payload = protected function getAccountPrivateKey(): string { - return file_get_contents($this->client->getAccountKeysPath() . 'private.pem'); + return $this->client->keyStorage->getPrivateKey(); } } diff --git a/src/Interfaces/KeyStorageInterface.php b/src/Interfaces/KeyStorageInterface.php new file mode 100644 index 0000000..075da81 --- /dev/null +++ b/src/Interfaces/KeyStorageInterface.php @@ -0,0 +1,12 @@ + OPENSSL_KEYTYPE_RSA, @@ -19,7 +22,9 @@ public static function generate(string $directory): void $details = openssl_pkey_get_details($pKey); - file_put_contents($directory . 'private.pem', $privateKey); - file_put_contents($directory . 'public.pem', $details['key']); + return [ + 'privateKey' => $privateKey, + 'publicKey' => $details['key'], + ]; } } diff --git a/src/Support/JsonWebKey.php b/src/Support/JsonWebKey.php index cc8c4d7..8e54801 100644 --- a/src/Support/JsonWebKey.php +++ b/src/Support/JsonWebKey.php @@ -6,7 +6,9 @@ class JsonWebKey { - public static function compute(string $accountKey): array + public static function compute( + #[\SensitiveParameter] string $accountKey + ): array { $privateKey = openssl_pkey_get_private($accountKey); @@ -17,14 +19,14 @@ public static function compute(string $accountKey): array $details = openssl_pkey_get_details($privateKey); return [ - 'e' => Base64::urlSafeEncode($details['rsa']['e']), + 'e' => Base64::urlSafeEncode($details['rsa']['e']), 'kty' => 'RSA', - 'n' => Base64::urlSafeEncode($details['rsa']['n']), + 'n' => Base64::urlSafeEncode($details['rsa']['n']), ]; } public static function thumbprint(array $jwk): string { - return Base64::urlSafeEncode(hash('sha256', json_encode($jwk), true)); + return Base64::urlSafeEncode(hash('sha256', json_encode($jwk, JSON_THROW_ON_ERROR), true)); } } diff --git a/src/Support/JsonWebSignature.php b/src/Support/JsonWebSignature.php index a520f5f..a628369 100644 --- a/src/Support/JsonWebSignature.php +++ b/src/Support/JsonWebSignature.php @@ -4,29 +4,32 @@ class JsonWebSignature { - public static function generate(array $payload, string $url, string $nonce, string $accountKeysPath): array + public static function generate( + array $payload, + string $url, + string $nonce, + #[\SensitiveParameter] string $accountPrivateKey + ): array { - $accountKey = file_get_contents($accountKeysPath . 'private.pem'); - - $privateKey = openssl_pkey_get_private($accountKey); + $privateKey = openssl_pkey_get_private($accountPrivateKey); $protected = [ 'alg' => 'RS256', - 'jwk' => JsonWebKey::compute($accountKey), + 'jwk' => JsonWebKey::compute($accountPrivateKey), 'nonce' => $nonce, - 'url' => $url, + 'url' => $url, ]; - $payload64 = Base64::urlSafeEncode(str_replace('\\/', '/', json_encode($payload))); - $protected64 = Base64::urlSafeEncode(json_encode($protected)); + $payload64 = Base64::urlSafeEncode(str_replace('\\/', '/', json_encode($payload, JSON_THROW_ON_ERROR))); + $protected64 = Base64::urlSafeEncode(json_encode($protected, JSON_THROW_ON_ERROR)); - openssl_sign($protected64 . '.' . $payload64, $signed, $privateKey, 'SHA256'); + openssl_sign($protected64.'.'.$payload64, $signed, $privateKey, 'SHA256'); $signed64 = Base64::urlSafeEncode($signed); return [ 'protected' => $protected64, - 'payload' => $payload64, + 'payload' => $payload64, 'signature' => $signed64, ]; } diff --git a/src/Support/KeyId.php b/src/Support/KeyId.php index f9d4d51..34cc041 100644 --- a/src/Support/KeyId.php +++ b/src/Support/KeyId.php @@ -4,15 +4,21 @@ class KeyId { - public static function generate(string $accountKeysPath, string $kid, string $url, string $nonce, ?array $payload = null): array + public static function generate( + #[\SensitiveParameter] string $accountPrivateKey, + string $kid, + string $url, + string $nonce, + ?array $payload = null + ): array { - $privateKey = openssl_pkey_get_private(file_get_contents($accountKeysPath . 'private.pem')); + $privateKey = openssl_pkey_get_private($accountPrivateKey); $data = [ - 'alg' => 'RS256', - 'kid' => $kid, + 'alg' => 'RS256', + 'kid' => $kid, 'nonce' => $nonce, - 'url' => $url, + 'url' => $url, ]; $payload = is_array($payload) @@ -23,7 +29,7 @@ public static function generate(string $accountKeysPath, string $kid, string $ur $protected64 = Base64::urlSafeEncode(json_encode($data)); openssl_sign( - $protected64 . '.' . $payload64, + $protected64.'.'.$payload64, $signed, $privateKey, 'SHA256' @@ -33,7 +39,7 @@ public static function generate(string $accountKeysPath, string $kid, string $ur return [ 'protected' => $protected64, - 'payload' => $payload64, + 'payload' => $payload64, 'signature' => $signed64, ]; } diff --git a/src/Support/KeyStorage/FileKeyStorage.php b/src/Support/KeyStorage/FileKeyStorage.php new file mode 100644 index 0000000..b7721cf --- /dev/null +++ b/src/Support/KeyStorage/FileKeyStorage.php @@ -0,0 +1,105 @@ +accountKeysPath = rtrim($this->accountKeysPath, '/').'/'; + } + + public function getPrivateKey(): string + { + return $this->getKey('private'); + } + + public function getPublicKey(): string + { + return $this->getKey('public'); + } + + public function exists(): bool + { + if (is_dir($this->accountKeysPath)) { + return is_file($this->accountKeysPath.$this->getKeyName('private')) + && is_file($this->accountKeysPath.$this->getKeyName('public')); + } + + return false; + } + + public function generateNewKeys(string $keyType = 'RSA'): bool + { + if ($keyType !== 'RSA') { + throw new LetsEncryptClientException('Key type is not supported.'); + } + + $concurrentDirectory = rtrim($this->accountKeysPath, DIRECTORY_SEPARATOR).DIRECTORY_SEPARATOR; + + if (!is_dir($concurrentDirectory) && !mkdir($concurrentDirectory) && !is_dir($concurrentDirectory)) { + throw new LetsEncryptClientException(sprintf('Directory "%s" was not created', $concurrentDirectory)); + } + + $keys = CryptRSA::generate(); + + if (!isset($keys['privateKey'], $keys['publicKey'])) { + throw new LetsEncryptClientException('Key generation failed.'); + } + + $privateKeyPath = $concurrentDirectory.$this->getKeyName('private'); + $publicKeyPath = $concurrentDirectory.$this->getKeyName('public'); + + if (file_put_contents($privateKeyPath, $keys['privateKey']) === false || + file_put_contents($publicKeyPath, $keys['publicKey']) === false) { + throw new LetsEncryptClientException('Failed to write keys to files.'); + } + + return true; + } + + public function setAccountName(string $accountName): self + { + $this->accountName = $accountName; + + return $this; + } + + public function getAccountName(): string + { + return $this->accountName; + } + + protected function getKey(string $type): string + { + $filePath = $this->accountKeysPath.$this->getKeyName($type); + + if (!file_exists($filePath)) { + throw new LetsEncryptClientException(sprintf("[%s] File does not exist", $filePath)); + } + + $content = file_get_contents($filePath); + + if ($content === false) { + throw new LetsEncryptClientException(sprintf("[%s] Failed to get contents of the file", $filePath)); + } + + return $content; + } + + private function getKeyName(string $type): string + { + if (empty($this->accountName)) { + throw new LetsEncryptClientException('Account name is not set.'); + } + + return sprintf('%s-%s.pem', $this->accountName, $type); + } +} diff --git a/tests/Unit/ApiTest.php b/tests/Unit/ApiTest.php new file mode 100644 index 0000000..47e4131 --- /dev/null +++ b/tests/Unit/ApiTest.php @@ -0,0 +1,15 @@ +keyStorage->getAccountName(); + + $api->useAccount('test&test@example.com'); + $name2 = $api->keyStorage->getAccountName(); + + expect($name1)->not()->toBe($name2); +}); From 6b21ddf6e0dbb3a37cbd613796e36f3898257084 Mon Sep 17 00:00:00 2001 From: Tristan Date: Fri, 10 Nov 2023 13:58:15 +0100 Subject: [PATCH 03/15] Update readme --- README.md | 16 +++++++++++++++- 1 file changed, 15 insertions(+), 1 deletion(-) diff --git a/README.md b/README.md index 5a4889a..6a147bc 100644 --- a/README.md +++ b/README.md @@ -26,9 +26,23 @@ You can install the package via composer: You can create an instance of `Rogierw\RwAcme\Api` client. ```php -$client = new Api('test@example.com', __DIR__ . '/__account'); +$client = new Api(__DIR__ . '/__account', 'test@example.com'); +// or with a custom key storage: +$myKeyStore = new MyKeyStore(); +$client = new Api($myKeyStore, 'test@example.com'); ``` +Also, you can create a client instance without specifying an account name (email address): +```php +$client = new Api(__DIR__ . '/__account'); // or: $client = new Api($myKeyStore); + +// You can later set the account name: +$client->useAccount('test@example.com'); +``` + +> Please note that **setting an account name is required** before making any of the calls detailed below. +> If you don't set an account name, the client will throw an exception. + ### Creating an account ```php if (!$client->account()->exists()) { From 85e7791a208b910ac20c564b1f3e799125ff8c22 Mon Sep 17 00:00:00 2001 From: Tristan Date: Fri, 10 Nov 2023 15:24:16 +0100 Subject: [PATCH 04/15] Change KeyStorage to LocalAccount --- README.md | 26 ++++++----- src/Api.php | 42 +++++++----------- src/Endpoints/Account.php | 10 ++--- src/Endpoints/Endpoint.php | 4 +- src/Interfaces/AcmeAccountInterface.php | 13 ++++++ src/Interfaces/KeyStorageInterface.php | 12 ------ ...ileKeyStorage.php => LocalFileAccount.php} | 43 +++++++++++-------- tests/Unit/ApiTest.php | 15 ------- 8 files changed, 76 insertions(+), 89 deletions(-) create mode 100644 src/Interfaces/AcmeAccountInterface.php delete mode 100644 src/Interfaces/KeyStorageInterface.php rename src/Support/{KeyStorage/FileKeyStorage.php => LocalFileAccount.php} (76%) delete mode 100644 tests/Unit/ApiTest.php diff --git a/README.md b/README.md index 6a147bc..7f8cbf5 100644 --- a/README.md +++ b/README.md @@ -23,25 +23,25 @@ You can install the package via composer: ## Usage -You can create an instance of `Rogierw\RwAcme\Api` client. +Create an instance of `Rogierw\RwAcme\Api` client and provide it with a local account that will be used to store the account keys. ```php -$client = new Api(__DIR__ . '/__account', 'test@example.com'); -// or with a custom key storage: -$myKeyStore = new MyKeyStore(); -$client = new Api($myKeyStore, 'test@example.com'); +$localAccount = new \Rogierw\RwAcme\Support\LocalFileAccount(__DIR__.'/__account', 'test@example.com'); +$client = new Api($localAccount); ``` -Also, you can create a client instance without specifying an account name (email address): +You could also create a client and pass the user data class later: + ```php -$client = new Api(__DIR__ . '/__account'); // or: $client = new Api($myKeyStore); +$client = new Api(); + +// Do some stuff. -// You can later set the account name: -$client->useAccount('test@example.com'); +$localAccount = new \Rogierw\RwAcme\Support\LocalFileAccount(__DIR__.'/__account', 'test@example.com'); +$client->setLocalAccount($localAccount); ``` -> Please note that **setting an account name is required** before making any of the calls detailed below. -> If you don't set an account name, the client will throw an exception. +> Please note that **setting a local account is required** before making any of the calls detailed below. ### Creating an account ```php @@ -53,6 +53,10 @@ if (!$client->account()->exists()) { $account = $client->account()->get(); ``` +### Difference between `account` and `localAccount` +- `account` is the account created at the ACME (Let's Encrypt) server with data from the `localAccount`. +- `localAccount` handles the private/public key pair and contact email address used to sign requests to the ACME server. Depending on the implementation, this data is stored locally or, for example, in a database. + ### Creating an order ```php $order = $client->order()->new($account, ['example.com']); diff --git a/src/Api.php b/src/Api.php index 19d2488..a587874 100644 --- a/src/Api.php +++ b/src/Api.php @@ -9,9 +9,9 @@ use Rogierw\RwAcme\Endpoints\DomainValidation; use Rogierw\RwAcme\Endpoints\Nonce; use Rogierw\RwAcme\Endpoints\Order; +use Rogierw\RwAcme\Exceptions\LetsEncryptClientException; use Rogierw\RwAcme\Http\Client; -use Rogierw\RwAcme\Interfaces\KeyStorageInterface; -use Rogierw\RwAcme\Support\KeyStorage\FileKeyStorage; +use Rogierw\RwAcme\Interfaces\AcmeAccountInterface; class Api { @@ -20,38 +20,31 @@ class Api private string $baseUrl; private Client $httpClient; - public KeyStorageInterface $keyStorage; public function __construct( - KeyStorageInterface|string $keyStorage, - private readonly ?string $accountEmail = null, - bool $staging = false, - private ?LoggerInterface $logger = null + private ?AcmeAccountInterface $localAccount = null, + bool $staging = false, + private ?LoggerInterface $logger = null ) { $this->baseUrl = $staging ? self::STAGING_URL : self::PRODUCTION_URL; $this->httpClient = new Client(); + } - // If a string is passed, create a FileKeyStorage instance with the string as the path. - if (is_string($keyStorage)) { - $this->keyStorage = new FileKeyStorage($keyStorage); - } else { - $this->keyStorage = $keyStorage; - } + public function setLocalAccount(AcmeAccountInterface $account): self + { + $this->localAccount = $account; - if ($this->accountEmail !== null) { - $this->useAccount($this->accountEmail); - } + return $this; } - public function useAccount(string $accountName): self + public function localAccount(): AcmeAccountInterface { - $alphaNumAccountName = preg_replace('/[^a-zA-Z0-9\-]/', '_', $accountName); - $shortHash = substr(hash('sha256', $accountName), 0, 16); - // Set/change the account name to allow for multiple accounts to be used. - $this->keyStorage->setAccountName($shortHash.'_'.$alphaNumAccountName); + if ($this->localAccount === null) { + throw new LetsEncryptClientException('No account set.'); + } - return $this; + return $this->localAccount; } public function directory(): Directory @@ -84,11 +77,6 @@ public function certificate(): Certificate return new Certificate($this); } - public function getAccountEmail(): string - { - return $this->accountEmail; - } - public function getBaseUrl(): string { return $this->baseUrl; diff --git a/src/Endpoints/Account.php b/src/Endpoints/Account.php index e7f67d0..e16d7da 100644 --- a/src/Endpoints/Account.php +++ b/src/Endpoints/Account.php @@ -10,15 +10,15 @@ class Account extends Endpoint { public function exists(): bool { - return $this->client->keyStorage->exists(); + return $this->client->localAccount()->exists(); } public function create(): AccountData { - $this->client->keyStorage->generateNewKeys(); + $this->client->localAccount()->generateNewKeys(); $payload = [ - 'contact' => $this->buildContactPayload($this->client->getAccountEmail()), + 'contact' => $this->buildContactPayload($this->client->localAccount()->getEmailAddress()), 'termsOfServiceAgreed' => true, ]; @@ -28,7 +28,7 @@ public function create(): AccountData $payload, $newAccountUrl, $this->client->nonce()->getNew(), - $this->client->keyStorage->getPrivateKey(), + $this->client->localAccount()->getPrivateKey(), ); $response = $this->client->getHttpClient()->post( @@ -59,7 +59,7 @@ public function get(): AccountData $payload, $newAccountUrl, $this->client->nonce()->getNew(), - $this->client->keyStorage->getPrivateKey(), + $this->client->localAccount()->getPrivateKey(), ); $response = $this->client->getHttpClient()->post($newAccountUrl, $signedPayload); diff --git a/src/Endpoints/Endpoint.php b/src/Endpoints/Endpoint.php index 265eed4..9f8b304 100644 --- a/src/Endpoints/Endpoint.php +++ b/src/Endpoints/Endpoint.php @@ -14,7 +14,7 @@ public function __construct(protected Api $client) protected function createKeyId(string $accountUrl, string $url, ?array $payload = null): array { return KeyId::generate( - $this->client->keyStorage->getPrivateKey(), + $this->client->localAccount()->getPrivateKey(), $accountUrl, $url, $this->client->nonce()->getNew(), @@ -24,6 +24,6 @@ protected function createKeyId(string $accountUrl, string $url, ?array $payload protected function getAccountPrivateKey(): string { - return $this->client->keyStorage->getPrivateKey(); + return $this->client->localAccount()->getPrivateKey(); } } diff --git a/src/Interfaces/AcmeAccountInterface.php b/src/Interfaces/AcmeAccountInterface.php new file mode 100644 index 0000000..4adb699 --- /dev/null +++ b/src/Interfaces/AcmeAccountInterface.php @@ -0,0 +1,13 @@ +accountKeysPath = rtrim($this->accountKeysPath, '/').'/'; + + if ($emailAddress !== null) { + $this->setEmailAddress($emailAddress); + } + } + + public function setEmailAddress(string $emailAddress): self + { + $alphaNumAccountName = preg_replace('/[^a-zA-Z0-9\-]/', '_', $emailAddress); + // Prepend a hash to prevent collisions. + $shortHash = substr(hash('sha256', $emailAddress), 0, 16); + + $this->emailAddress = $emailAddress; + $this->accountName = $shortHash.'_'.$alphaNumAccountName; + + return $this; + } + + public function getEmailAddress(): string + { + return $this->emailAddress; } public function getPrivateKey(): string @@ -65,18 +86,6 @@ public function generateNewKeys(string $keyType = 'RSA'): bool return true; } - public function setAccountName(string $accountName): self - { - $this->accountName = $accountName; - - return $this; - } - - public function getAccountName(): string - { - return $this->accountName; - } - protected function getKey(string $type): string { $filePath = $this->accountKeysPath.$this->getKeyName($type); diff --git a/tests/Unit/ApiTest.php b/tests/Unit/ApiTest.php deleted file mode 100644 index 47e4131..0000000 --- a/tests/Unit/ApiTest.php +++ /dev/null @@ -1,15 +0,0 @@ -keyStorage->getAccountName(); - - $api->useAccount('test&test@example.com'); - $name2 = $api->keyStorage->getAccountName(); - - expect($name1)->not()->toBe($name2); -}); From 28e845e7f4e52ff732e2745cc61c41bb60265fe7 Mon Sep 17 00:00:00 2001 From: Tristan Date: Fri, 10 Nov 2023 15:24:25 +0100 Subject: [PATCH 05/15] Remove unit test --- composer.json | 6 +----- 1 file changed, 1 insertion(+), 5 deletions(-) diff --git a/composer.json b/composer.json index 9bf445e..239999a 100644 --- a/composer.json +++ b/composer.json @@ -21,8 +21,7 @@ "spatie/laravel-data": "^3.5" }, "require-dev": { - "larapack/dd": "^1.0", - "pestphp/pest": "^2.24" + "larapack/dd": "^1.0" }, "autoload": { "psr-4": { @@ -34,8 +33,5 @@ }, "config": { "sort-packages": true, - "allow-plugins": { - "pestphp/pest-plugin": true - } } } From 7c91c2f75f77dbea2c46f2728f778df72494dba0 Mon Sep 17 00:00:00 2001 From: Tristan Date: Fri, 10 Nov 2023 15:26:06 +0100 Subject: [PATCH 06/15] Fix typo --- README.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/README.md b/README.md index 7f8cbf5..3d1aaa0 100644 --- a/README.md +++ b/README.md @@ -30,7 +30,7 @@ $localAccount = new \Rogierw\RwAcme\Support\LocalFileAccount(__DIR__.'/__account $client = new Api($localAccount); ``` -You could also create a client and pass the user data class later: +You could also create a client and pass the local account data later: ```php $client = new Api(); From 38c0ad96d2bba0d4a92b9b38153fda7db8a5058d Mon Sep 17 00:00:00 2001 From: Tristan Date: Tue, 14 Nov 2023 11:27:16 +0100 Subject: [PATCH 07/15] Add HttpClientInterface and implementation --- composer.json | 2 +- src/Api.php | 28 +++++++---- src/DTO/AccountData.php | 2 +- src/DTO/OrderData.php | 4 +- src/Endpoints/Account.php | 65 ++++++++++++-------------- src/Endpoints/Certificate.php | 6 +-- src/Endpoints/DomainValidation.php | 3 +- src/Endpoints/Nonce.php | 2 +- src/Http/Client.php | 57 +++++++++++++++------- src/Http/Response.php | 49 +++++++------------ src/Interfaces/HttpClientInterface.php | 16 +++++++ src/Support/LocalChallengeTest.php | 7 ++- src/Support/Str.php | 28 ----------- 13 files changed, 136 insertions(+), 133 deletions(-) create mode 100644 src/Interfaces/HttpClientInterface.php delete mode 100644 src/Support/Str.php diff --git a/composer.json b/composer.json index 239999a..8588739 100644 --- a/composer.json +++ b/composer.json @@ -32,6 +32,6 @@ ] }, "config": { - "sort-packages": true, + "sort-packages": true } } diff --git a/src/Api.php b/src/Api.php index a587874..3f73313 100644 --- a/src/Api.php +++ b/src/Api.php @@ -12,6 +12,7 @@ use Rogierw\RwAcme\Exceptions\LetsEncryptClientException; use Rogierw\RwAcme\Http\Client; use Rogierw\RwAcme\Interfaces\AcmeAccountInterface; +use Rogierw\RwAcme\Interfaces\HttpClientInterface; class Api { @@ -19,16 +20,15 @@ class Api private const STAGING_URL = 'https://acme-staging-v02.api.letsencrypt.org'; private string $baseUrl; - private Client $httpClient; public function __construct( - private ?AcmeAccountInterface $localAccount = null, - bool $staging = false, - private ?LoggerInterface $logger = null + bool $staging = false, + private ?AcmeAccountInterface $localAccount = null, + private ?LoggerInterface $logger = null, + private HttpClientInterface|null $httpClient = null, ) { $this->baseUrl = $staging ? self::STAGING_URL : self::PRODUCTION_URL; - $this->httpClient = new Client(); } public function setLocalAccount(AcmeAccountInterface $account): self @@ -82,11 +82,23 @@ public function getBaseUrl(): string return $this->baseUrl; } - public function getHttpClient(): Client + public function getHttpClient(): HttpClientInterface { + // Create a default client if none is set. + if ($this->httpClient === null) { + $this->httpClient = new Client(); + } + return $this->httpClient; } + public function setHttpClient(HttpClientInterface $httpClient): self + { + $this->httpClient = $httpClient; + + return $this; + } + public function setLogger(LoggerInterface $logger): self { $this->logger = $logger; @@ -94,10 +106,10 @@ public function setLogger(LoggerInterface $logger): self return $this; } - public function logger(string $level, string $message): void + public function logger(string $level, string $message, array $context = []): void { if ($this->logger instanceof LoggerInterface) { - $this->logger->log($level, $message); + $this->logger->log($level, $message, $context); } } } diff --git a/src/DTO/AccountData.php b/src/DTO/AccountData.php index 2586077..085071c 100644 --- a/src/DTO/AccountData.php +++ b/src/DTO/AccountData.php @@ -23,7 +23,7 @@ public function __construct( public static function fromResponse(Response $response): AccountData { - $url = trim(Arr::get($response->getRawHeaders(), 'Location', '')); + $url = trim($response->getHeader('location', '')); return new self( id: Url::extractId($url), diff --git a/src/DTO/OrderData.php b/src/DTO/OrderData.php index d97d8a9..2207ac1 100644 --- a/src/DTO/OrderData.php +++ b/src/DTO/OrderData.php @@ -25,10 +25,10 @@ public function __construct( public static function fromResponse(Response $response, string $accountUrl = ''): OrderData { - $url = Arr::get($response->getRawHeaders(), 'Location'); + $url = $response->getHeader('location'); if (empty($url)) { - $url = Arr::get($response->getHeaders(), 'url'); + $url = $response->getRequestedUrl(); } $url = trim(rtrim($url, '?')); diff --git a/src/Endpoints/Account.php b/src/Endpoints/Account.php index e16d7da..0d8dbad 100644 --- a/src/Endpoints/Account.php +++ b/src/Endpoints/Account.php @@ -4,6 +4,7 @@ use Rogierw\RwAcme\DTO\AccountData; use Rogierw\RwAcme\Exceptions\LetsEncryptClientException; +use Rogierw\RwAcme\Http\Response; use Rogierw\RwAcme\Support\JsonWebSignature; class Account extends Endpoint @@ -18,63 +19,59 @@ public function create(): AccountData $this->client->localAccount()->generateNewKeys(); $payload = [ - 'contact' => $this->buildContactPayload($this->client->localAccount()->getEmailAddress()), + 'contact' => ['mailto:'.$this->client->localAccount()->getEmailAddress()], 'termsOfServiceAgreed' => true, ]; - $newAccountUrl = $this->client->directory()->newAccount(); + $response = $this->postToAccountUrl($payload); - $signedPayload = JsonWebSignature::generate( - $payload, - $newAccountUrl, - $this->client->nonce()->getNew(), - $this->client->localAccount()->getPrivateKey(), - ); - - $response = $this->client->getHttpClient()->post( - $newAccountUrl, - $signedPayload - ); - - if ($response->getHttpResponseCode() === 201 && array_key_exists('Location', $response->getRawHeaders())) { + if ($response->getHttpResponseCode() === 201 && $response->hasHeader('location')) { return AccountData::fromResponse($response); } - throw new LetsEncryptClientException('Creating account failed.'); + $this->throwError($response, 'Creating account failed'); } public function get(): AccountData { if (!$this->exists()) { - throw new LetsEncryptClientException('Account keys not found.'); + throw new LetsEncryptClientException('Local account keys not found.'); } - $payload = [ - 'onlyReturnExisting' => true, - ]; + // Use the newAccountUrl to get the account data based on the key. + // See https://datatracker.ietf.org/doc/html/rfc8555#section-7.3.1 + $payload = ['onlyReturnExisting' => true]; + $response = $this->postToAccountUrl($payload); - $newAccountUrl = $this->client->directory()->newAccount(); + if ($response->getHttpResponseCode() === 200) { + return AccountData::fromResponse($response); + } - $signedPayload = JsonWebSignature::generate( + $this->throwError($response, 'Retrieving account failed'); + } + + private function signPayload(array $payload): array + { + return JsonWebSignature::generate( $payload, - $newAccountUrl, + $this->client->directory()->newAccount(), $this->client->nonce()->getNew(), $this->client->localAccount()->getPrivateKey(), ); + } - $response = $this->client->getHttpClient()->post($newAccountUrl, $signedPayload); - - if ($response->getHttpResponseCode() === 400) { - throw new LetsEncryptClientException($response->getBody()); - } - - return AccountData::fromResponse($response); + private function postToAccountUrl(array $payload): Response + { + return $this->client->getHttpClient()->post( + $this->client->directory()->newAccount(), + $this->signPayload($payload) + ); } - private function buildContactPayload(string $email): array + protected function throwError(Response $response, string $defaultMessage): never { - return [ - 'mailto:' . $email, - ]; + $message = $response->getBody()['details'] ?? $defaultMessage; + $this->client->logger('error', $message, ['response' => $response->getBody()]); + throw new LetsEncryptClientException($message); } } diff --git a/src/Endpoints/Certificate.php b/src/Endpoints/Certificate.php index 7b5003b..861195d 100644 --- a/src/Endpoints/Certificate.php +++ b/src/Endpoints/Certificate.php @@ -48,10 +48,6 @@ public function revoke(string $pem, int $reason = 0): bool $response = $this->client->getHttpClient()->post($revokeUrl, $signedPayload); - if ($response->getHttpResponseCode() === 200) { - return true; - } - - return false; + return $response->getHttpResponseCode() === 200; } } diff --git a/src/Endpoints/DomainValidation.php b/src/Endpoints/DomainValidation.php index 153d613..d08c82d 100644 --- a/src/Endpoints/DomainValidation.php +++ b/src/Endpoints/DomainValidation.php @@ -87,7 +87,8 @@ public function start( LocalChallengeTest::http( $domainValidation->identifier['value'], $domainValidation->file['token'], - $keyAuthorization + $keyAuthorization, + $this->client->getHttpClient() ); } diff --git a/src/Endpoints/Nonce.php b/src/Endpoints/Nonce.php index 5f4c429..426937d 100644 --- a/src/Endpoints/Nonce.php +++ b/src/Endpoints/Nonce.php @@ -10,6 +10,6 @@ public function getNew(): string ->getHttpClient() ->head($this->client->directory()->newNonce()); - return trim($response->getRawHeaders()['Replay-Nonce']); + return trim($response->getHeader('replay-nonce')); } } diff --git a/src/Http/Client.php b/src/Http/Client.php index 2e9270f..69070aa 100644 --- a/src/Http/Client.php +++ b/src/Http/Client.php @@ -3,10 +3,11 @@ namespace Rogierw\RwAcme\Http; use CurlHandle; +use Rogierw\RwAcme\Interfaces\HttpClientInterface; -class Client +class Client implements HttpClientInterface { - public function __construct(private int $timeout = 10, private int $maxRedirects = 0) + public function __construct(private readonly int $timeout = 10) { } @@ -15,25 +16,25 @@ public function head(string $url): Response return $this->makeCurlRequest('head', $url); } - public function get(string $url, array $headers = [], array $arguments = []): Response + public function get(string $url, array $headers = [], array $arguments = [], int $maxRedirects = 0): Response { - return $this->makeCurlRequest('get', $url, $headers, $arguments); + return $this->makeCurlRequest('get', $url, $headers, $arguments, $maxRedirects); } - public function post(string $url, array $payload = [], array $headers = []): Response + public function post(string $url, array $payload = [], array $headers = [], int $maxRedirects = 0): Response { $headers = array_merge(['Content-Type: application/jose+json'], $headers); - return $this->makeCurlRequest('post', $url, $headers, $payload); + return $this->makeCurlRequest('post', $url, $headers, $payload, $maxRedirects); } - public function makeCurlRequest(string $httpVerb, string $fullUrl, array $headers = [], array $payload = []): Response + private function makeCurlRequest(string $httpVerb, string $fullUrl, array $headers = [], array $payload = [], int $maxRedirects = 0): Response { $headers = array_merge([ 'Content-Type: ' . ($httpVerb === 'post') ? 'application/jose+json' : 'application/json', ], $headers); - $curlHandle = $this->getCurlHandle($fullUrl, $headers); + $curlHandle = $this->getCurlHandle($fullUrl, $headers, $maxRedirects); switch ($httpVerb) { case 'head': @@ -54,27 +55,31 @@ public function makeCurlRequest(string $httpVerb, string $fullUrl, array $header $rawResponse = curl_exec($curlHandle); $headerSize = curl_getinfo($curlHandle, CURLINFO_HEADER_SIZE); $headers = curl_getinfo($curlHandle); - $error = curl_error($curlHandle); $rawHeaders = mb_substr($rawResponse, 0, $headerSize); $rawBody = mb_substr($rawResponse, $headerSize); $body = $rawBody; - if ($headers['content_type'] === 'application/json') { - $body = json_decode($rawBody, true); + if ( + $headers['content-type'] === 'application/json' || + $headers['content-type'] === 'application/problem+json' + ) { + $body = json_decode($rawBody, true, 512, JSON_THROW_ON_ERROR); } - return new Response($rawHeaders, $headers, $body, $error); + $parsedRawHeaders = $this->parseRawHeaders($rawHeaders); + + return new Response($headers, $parsedRawHeaders['url'] ?? '', $parsedRawHeaders['http_code'] ?? null, $body); } - private function attachRequestPayload(CurlHandle &$curlHandle, array $data): void + private function attachRequestPayload(CurlHandle $curlHandle, array $data): void { - $encoded = json_encode($data); + $encoded = json_encode($data, JSON_THROW_ON_ERROR); curl_setopt($curlHandle, CURLOPT_POSTFIELDS, $encoded); } - private function getCurlHandle(string $fullUrl, array $headers = []): CurlHandle + private function getCurlHandle(string $fullUrl, array $headers = [], int $maxRedirects = 0): CurlHandle { $curlHandle = curl_init(); @@ -92,11 +97,29 @@ private function getCurlHandle(string $fullUrl, array $headers = []): CurlHandle curl_setopt($curlHandle, CURLOPT_ENCODING, ''); curl_setopt($curlHandle, CURLOPT_HEADER, true); - if ($this->maxRedirects > 0) { + if ($maxRedirects > 0) { curl_setopt($curlHandle, CURLOPT_FOLLOWLOCATION, true); - curl_setopt($curlHandle, CURLOPT_MAXREDIRS, $this->maxRedirects); + curl_setopt($curlHandle, CURLOPT_MAXREDIRS, $maxRedirects); } return $curlHandle; } + + private function parseRawHeaders(string $rawHeaders): array + { + $headers = explode("\n", $rawHeaders); + $headersArr = []; + + foreach ($headers as $header) { + if (!str_contains($header, ':')) { + continue; + } + + [$name, $value] = explode(':', $header, 2); + + $headersArr[strtolower($name)] = trim($value); + } + + return $headersArr; + } } diff --git a/src/Http/Response.php b/src/Http/Response.php index 117412a..ccd6a0b 100644 --- a/src/Http/Response.php +++ b/src/Http/Response.php @@ -2,34 +2,20 @@ namespace Rogierw\RwAcme\Http; -use Rogierw\RwAcme\Support\Str; - class Response { public function __construct( - private string $rawHeaders, - private array $headers, - private array|string $body, - private string $error - ) { + private readonly array $headers, + private readonly string $requestedUrl, + private readonly ?int $statusCode, + private readonly array|string $body, + ) + { } - public function getRawHeaders(): array + public function getHeader(string $name, $default = null): mixed { - $headers = explode("\n", $this->rawHeaders); - $headersArr = []; - - foreach ($headers as $header) { - if (!Str::contains($header, ':')) { - continue; - } - - [$name, $value] = explode(':', $header, 2); - - $headersArr[$name] = $value; - } - - return $headersArr; + return $this->headers[$name] ?? $default; } public function getHeaders(): array @@ -37,27 +23,28 @@ public function getHeaders(): array return $this->headers; } + public function hasHeader(string $name): bool + { + return isset($this->headers[$name]); + } + public function getBody(): array|string { return $this->body; } - public function hasBody(): bool + public function getRequestedUrl(): string { - return $this->body != false; + return $this->requestedUrl; } - public function getError(): string + public function hasBody(): bool { - return $this->error; + return !empty($this->body); } public function getHttpResponseCode(): ?int { - if (!isset($this->headers['http_code'])) { - return null; - } - - return (int) $this->headers['http_code']; + return $this->statusCode; } } diff --git a/src/Interfaces/HttpClientInterface.php b/src/Interfaces/HttpClientInterface.php new file mode 100644 index 0000000..ea7d8e6 --- /dev/null +++ b/src/Interfaces/HttpClientInterface.php @@ -0,0 +1,16 @@ +get($domain . '/.well-known/acme-challenge/' . $token); + $response = $httpClient->get($domain . '/.well-known/acme-challenge/' . $token, maxRedirects: 1); $body = $response->getBody(); diff --git a/src/Support/Str.php b/src/Support/Str.php deleted file mode 100644 index 8d40108..0000000 --- a/src/Support/Str.php +++ /dev/null @@ -1,28 +0,0 @@ - Date: Mon, 20 Nov 2023 13:44:14 +0100 Subject: [PATCH 08/15] Add logging for failed requests --- src/Endpoints/Account.php | 2 +- src/Endpoints/Certificate.php | 5 ++++ src/Endpoints/Directory.php | 10 +++++++- src/Endpoints/DomainValidation.php | 14 +++++++++- src/Endpoints/Endpoint.php | 11 ++++++++ src/Endpoints/Order.php | 27 +++++++++++--------- src/Exceptions/DomainValidationException.php | 2 +- src/Exceptions/OrderNotFoundException.php | 7 +++++ src/Exceptions/RateLimitException.php | 8 ++++++ 9 files changed, 70 insertions(+), 16 deletions(-) create mode 100644 src/Exceptions/OrderNotFoundException.php create mode 100644 src/Exceptions/RateLimitException.php diff --git a/src/Endpoints/Account.php b/src/Endpoints/Account.php index 0d8dbad..5e0f13c 100644 --- a/src/Endpoints/Account.php +++ b/src/Endpoints/Account.php @@ -71,7 +71,7 @@ private function postToAccountUrl(array $payload): Response protected function throwError(Response $response, string $defaultMessage): never { $message = $response->getBody()['details'] ?? $defaultMessage; - $this->client->logger('error', $message, ['response' => $response->getBody()]); + $this->logResponse('error', $message, $response); throw new LetsEncryptClientException($message); } } diff --git a/src/Endpoints/Certificate.php b/src/Endpoints/Certificate.php index 861195d..550780a 100644 --- a/src/Endpoints/Certificate.php +++ b/src/Endpoints/Certificate.php @@ -16,6 +16,7 @@ public function getBundle(OrderData $orderData): CertificateBundleData $response = $this->client->getHttpClient()->post($orderData->certificateUrl, $signedPayload); if ($response->getHttpResponseCode() !== 200) { + $this->logResponse('error', 'Failed to fetch certificate', $response); throw new LetsEncryptClientException('Failed to fetch certificate.'); } @@ -48,6 +49,10 @@ public function revoke(string $pem, int $reason = 0): bool $response = $this->client->getHttpClient()->post($revokeUrl, $signedPayload); + if ($response->getHttpResponseCode() !== 200) { + $this->logResponse('error', 'Failed to revoke certificate', $response); + } + return $response->getHttpResponseCode() === 200; } } diff --git a/src/Endpoints/Directory.php b/src/Endpoints/Directory.php index 02ef44c..ef094e6 100644 --- a/src/Endpoints/Directory.php +++ b/src/Endpoints/Directory.php @@ -2,15 +2,23 @@ namespace Rogierw\RwAcme\Endpoints; +use Rogierw\RwAcme\Exceptions\LetsEncryptClientException; use Rogierw\RwAcme\Http\Response; class Directory extends Endpoint { public function all(): Response { - return $this->client + $response = $this->client ->getHttpClient() ->get($this->client->getBaseUrl() . '/directory'); + + if ($response->getHttpResponseCode() >= 400) { + $this->logResponse('error', 'Cannot get directory', $response); + throw new LetsEncryptClientException('Cannot get directory'); + } + + return $response; } public function newNonce(): string diff --git a/src/Endpoints/DomainValidation.php b/src/Endpoints/DomainValidation.php index d08c82d..f61349b 100644 --- a/src/Endpoints/DomainValidation.php +++ b/src/Endpoints/DomainValidation.php @@ -31,6 +31,7 @@ public function status(OrderData $orderData): array if ($response->getHttpResponseCode() === 200) { $data[] = DomainValidationData::fromResponse($response); } + $this->logResponse('error', 'Cannot get domain validation', $response); } return $data; @@ -107,7 +108,18 @@ public function start( $data = $this->createKeyId($accountData->url, $domainValidation->{$type}['url'], $payload); - return $this->client->getHttpClient()->post($domainValidation->{$type}['url'], $data); + $response = $this->client->getHttpClient()->post($domainValidation->{$type}['url'], $data); + + if ($response->getHttpResponseCode() >= 400) { + $this->logResponse( + 'error', + $response->getBody()['detail'] ?? 'Unknown error', + $response, + ['payload' => $payload, 'data' => $data] + ); + } + + return $response; } public function allChallengesPassed(OrderData $orderData): bool diff --git a/src/Endpoints/Endpoint.php b/src/Endpoints/Endpoint.php index 9f8b304..e3072e7 100644 --- a/src/Endpoints/Endpoint.php +++ b/src/Endpoints/Endpoint.php @@ -3,6 +3,7 @@ namespace Rogierw\RwAcme\Endpoints; use Rogierw\RwAcme\Api; +use Rogierw\RwAcme\Http\Response; use Rogierw\RwAcme\Support\KeyId; abstract class Endpoint @@ -26,4 +27,14 @@ protected function getAccountPrivateKey(): string { return $this->client->localAccount()->getPrivateKey(); } + + protected function logResponse(string $level, string $message, Response $response, array $additionalContext = []): void + { + $this->client->logger($level, $message, array_merge([ + 'url' => $response->getRequestedUrl(), + 'status' => $response->getHttpResponseCode(), + 'headers' => $response->getHeaders(), + 'body' => $response->getBody(), + ], $additionalContext)); + } } diff --git a/src/Endpoints/Order.php b/src/Endpoints/Order.php index 225623a..4515d7c 100644 --- a/src/Endpoints/Order.php +++ b/src/Endpoints/Order.php @@ -39,11 +39,12 @@ public function new(AccountData $accountData, array $domains): OrderData $response = $this->client->getHttpClient()->post($newOrderUrl, $keyId); - if ($response->getHttpResponseCode() !== 201) { - throw new LetsEncryptClientException('Creating new order failed; bad response code.'); + if ($response->getHttpResponseCode() === 201) { + return OrderData::fromResponse($response, $accountData->url); } - return OrderData::fromResponse($response, $accountData->url); + $this->logResponse('error', 'Creating new order failed; bad response code.', $response, ['payload' => $payload]); + throw new LetsEncryptClientException('Creating new order failed; bad response code.'); } public function get(string $id): OrderData @@ -58,17 +59,19 @@ public function get(string $id): OrderData $response = $this->client->getHttpClient()->get($orderUrl); - if ($response->getHttpResponseCode() === 500) { - throw new LetsEncryptClientException($response->getBody()); + // Everything below 400 is a success. + if ($response->getHttpResponseCode() < 400) { + return OrderData::fromResponse($response, $account->url); } - if ($response->getHttpResponseCode() === 404) { - $this->client->logger('error', $response->getBody()); + // Always log the error. + $this->logResponse('error', 'Getting order failed; bad response code.', $response); - throw new LetsEncryptClientException('Order not found.'); - } - - return OrderData::fromResponse($response, $account->url); + match ($response->getHttpResponseCode()) { + 404 => throw new OrderNotFoundException($response->getBody()['detail'] ?? 'Order cannot be found.'), + 429 => throw new RateLimitException($response->getBody()['detail'] ?? 'Too many requests.'), + default => throw new LetsEncryptClientException($response->getBody()['detail'] ?? 'Unknown error.'), + }; } public function finalize(OrderData $orderData, string $csr): bool @@ -106,7 +109,7 @@ public function finalize(OrderData $orderData, string $csr): bool return true; } - $this->client->logger('error', 'Finalize order: ' . json_encode($response->getBody())); + $this->logResponse('error', 'Cannot finalize order '.$orderData->id, $response, ['orderData' => $orderData]); return false; } diff --git a/src/Exceptions/DomainValidationException.php b/src/Exceptions/DomainValidationException.php index 1d19baa..69a52d7 100644 --- a/src/Exceptions/DomainValidationException.php +++ b/src/Exceptions/DomainValidationException.php @@ -16,7 +16,7 @@ public static function localHttpChallengeTestFailed(string $domain, string $code public static function localDnsChallengeTestFailed(string $domain): self { return new static(sprintf( - "Couldn't fetch DNS records for %s.", + "Couldn't fetch the correct DNS records for %s.", $domain )); } diff --git a/src/Exceptions/OrderNotFoundException.php b/src/Exceptions/OrderNotFoundException.php new file mode 100644 index 0000000..634b220 --- /dev/null +++ b/src/Exceptions/OrderNotFoundException.php @@ -0,0 +1,7 @@ + Date: Mon, 20 Nov 2023 13:44:34 +0100 Subject: [PATCH 09/15] Add some additional checks --- src/Endpoints/DomainValidation.php | 32 +++++++++++++++++++++--------- src/Endpoints/Order.php | 12 ++++++----- src/Support/LocalChallengeTest.php | 4 ++-- 3 files changed, 32 insertions(+), 16 deletions(-) diff --git a/src/Endpoints/DomainValidation.php b/src/Endpoints/DomainValidation.php index f61349b..f181f26 100644 --- a/src/Endpoints/DomainValidation.php +++ b/src/Endpoints/DomainValidation.php @@ -6,6 +6,7 @@ use Rogierw\RwAcme\DTO\DomainValidationData; use Rogierw\RwAcme\DTO\OrderData; use Rogierw\RwAcme\Enums\AuthorizationChallengeEnum; +use Rogierw\RwAcme\Exceptions\DomainValidationException; use Rogierw\RwAcme\Http\Response; use Rogierw\RwAcme\Support\Arr; use Rogierw\RwAcme\Support\DnsDigest; @@ -31,6 +32,7 @@ public function status(OrderData $orderData): array if ($response->getHttpResponseCode() === 200) { $data[] = DomainValidationData::fromResponse($response); } + $this->logResponse('error', 'Cannot get domain validation', $response); } @@ -44,16 +46,22 @@ public function getValidationData(array $challenges, ?AuthorizationChallengeEnum $authorizations = []; foreach ($challenges as $domainValidationData) { - if ((is_null($authChallenge) || $authChallenge === AuthorizationChallengeEnum::HTTP)) { + if ( + (is_null($authChallenge) || $authChallenge === AuthorizationChallengeEnum::HTTP) + && !empty($domainValidationData->file) + ) { $authorizations[] = [ 'identifier' => $domainValidationData->identifier['value'], 'type' => $domainValidationData->file['type'], 'filename' => $domainValidationData->file['token'], - 'content' => $domainValidationData->file['token'] . '.' . $thumbprint, + 'content' => $domainValidationData->file['token'].'.'.$thumbprint, ]; } - if ((is_null($authChallenge) || $authChallenge === AuthorizationChallengeEnum::DNS)) { + if ( + (is_null($authChallenge) || $authChallenge === AuthorizationChallengeEnum::DNS) + && !empty($domainValidationData->dns) + ) { $authorizations[] = [ 'identifier' => $domainValidationData->identifier['value'], 'type' => $domainValidationData->dns['type'], @@ -68,11 +76,12 @@ public function getValidationData(array $challenges, ?AuthorizationChallengeEnum /** @throws \Rogierw\RwAcme\Exceptions\DomainValidationException */ public function start( - AccountData $accountData, - DomainValidationData $domainValidation, + AccountData $accountData, + DomainValidationData $domainValidation, AuthorizationChallengeEnum $authChallenge, - bool $localTest = true - ): Response { + bool $localTest = true + ): Response + { $this->client->logger('info', sprintf( 'Start %s challenge for %s', $authChallenge->value, @@ -81,7 +90,12 @@ public function start( $type = $authChallenge === AuthorizationChallengeEnum::DNS ? 'dns' : 'file'; $thumbprint = JsonWebKey::thumbprint(JsonWebKey::compute($this->getAccountPrivateKey())); - $keyAuthorization = $domainValidation->{$type}['token'] . '.' . $thumbprint; + + if (empty($domainValidation->{$type})) { + throw new DomainValidationException(sprintf('No %s challenge found for %s', $type, $domainValidation->identifier['value'])); + } + + $keyAuthorization = $domainValidation->{$type}['token'].'.'.$thumbprint; if ($localTest) { if ($authChallenge === AuthorizationChallengeEnum::HTTP) { @@ -147,7 +161,7 @@ public function allChallengesPassed(OrderData $orderData): bool /** @param DomainValidationData[] $domainValidation */ private function challengeSucceeded(array $domainValidation): bool { - // Verify if the challenges has been passed. + // Verify if the challenges have been passed. foreach ($domainValidation as $status) { $this->client->logger( 'info', diff --git a/src/Endpoints/Order.php b/src/Endpoints/Order.php index 4515d7c..6679b1d 100644 --- a/src/Endpoints/Order.php +++ b/src/Endpoints/Order.php @@ -5,6 +5,8 @@ use Rogierw\RwAcme\DTO\AccountData; use Rogierw\RwAcme\DTO\OrderData; use Rogierw\RwAcme\Exceptions\LetsEncryptClientException; +use Rogierw\RwAcme\Exceptions\OrderNotFoundException; +use Rogierw\RwAcme\Exceptions\RateLimitException; use Rogierw\RwAcme\Support\Base64; class Order extends Endpoint @@ -18,15 +20,15 @@ public function new(AccountData $accountData, array $domains): OrderData } $identifiers[] = [ - 'type' => 'dns', + 'type' => 'dns', 'value' => $domain, ]; } $payload = [ 'identifiers' => $identifiers, - 'notBefore' => '', - 'notAfter' => '', + 'notBefore' => '', + 'notAfter' => '', ]; $newOrderUrl = $this->client->directory()->newOrder(); @@ -51,11 +53,11 @@ public function get(string $id): OrderData { $account = $this->client->account()->get(); - $orderUrl = vsprintf('%s%s/%s', [ + $orderUrl = sprintf('%s%s/%s', $this->client->directory()->getOrder(), $account->id, $id, - ]); + ); $response = $this->client->getHttpClient()->get($orderUrl); diff --git a/src/Support/LocalChallengeTest.php b/src/Support/LocalChallengeTest.php index 1e9f584..51ec09d 100644 --- a/src/Support/LocalChallengeTest.php +++ b/src/Support/LocalChallengeTest.php @@ -15,7 +15,7 @@ public static function http(string $domain, string $token, string $keyAuthorizat $body = $response->getBody(); if (is_array($body)) { - $body = json_encode($body); + $body = json_encode($body, JSON_THROW_ON_ERROR); } if (trim($body) === $keyAuthorization) { @@ -24,7 +24,7 @@ public static function http(string $domain, string $token, string $keyAuthorizat throw DomainValidationException::localHttpChallengeTestFailed( $domain, - $response->getHttpResponseCode() ?? 'unknown' + $response->getHttpResponseCode() ); } From 474c665f748562a29da0d298b68bbe9a1a6e7cd2 Mon Sep 17 00:00:00 2001 From: Tristan Date: Mon, 20 Nov 2023 14:07:38 +0100 Subject: [PATCH 10/15] Apply StyleCI fixes --- src/Api.php | 9 ++++----- src/DTO/AccountData.php | 1 - src/Endpoints/Account.php | 1 + src/Endpoints/Certificate.php | 1 + src/Endpoints/Directory.php | 1 + src/Endpoints/DomainValidation.php | 9 ++++----- src/Endpoints/Order.php | 4 +++- src/Exceptions/RateLimitException.php | 1 - src/Http/Response.php | 9 ++++----- src/Interfaces/AcmeAccountInterface.php | 5 +++++ src/Support/JsonWebKey.php | 3 +-- src/Support/JsonWebSignature.php | 9 ++++----- src/Support/KeyId.php | 11 +++++------ src/Support/LocalChallengeTest.php | 1 - src/Support/LocalFileAccount.php | 4 ++-- 15 files changed, 35 insertions(+), 34 deletions(-) diff --git a/src/Api.php b/src/Api.php index 3f73313..f3e8c69 100644 --- a/src/Api.php +++ b/src/Api.php @@ -22,12 +22,11 @@ class Api private string $baseUrl; public function __construct( - bool $staging = false, - private ?AcmeAccountInterface $localAccount = null, - private ?LoggerInterface $logger = null, + bool $staging = false, + private ?AcmeAccountInterface $localAccount = null, + private ?LoggerInterface $logger = null, private HttpClientInterface|null $httpClient = null, - ) - { + ) { $this->baseUrl = $staging ? self::STAGING_URL : self::PRODUCTION_URL; } diff --git a/src/DTO/AccountData.php b/src/DTO/AccountData.php index 085071c..ffd931c 100644 --- a/src/DTO/AccountData.php +++ b/src/DTO/AccountData.php @@ -3,7 +3,6 @@ namespace Rogierw\RwAcme\DTO; use Rogierw\RwAcme\Http\Response; -use Rogierw\RwAcme\Support\Arr; use Rogierw\RwAcme\Support\Url; use Spatie\LaravelData\Data; diff --git a/src/Endpoints/Account.php b/src/Endpoints/Account.php index 5e0f13c..7a3c37b 100644 --- a/src/Endpoints/Account.php +++ b/src/Endpoints/Account.php @@ -72,6 +72,7 @@ protected function throwError(Response $response, string $defaultMessage): never { $message = $response->getBody()['details'] ?? $defaultMessage; $this->logResponse('error', $message, $response); + throw new LetsEncryptClientException($message); } } diff --git a/src/Endpoints/Certificate.php b/src/Endpoints/Certificate.php index 550780a..38398bf 100644 --- a/src/Endpoints/Certificate.php +++ b/src/Endpoints/Certificate.php @@ -17,6 +17,7 @@ public function getBundle(OrderData $orderData): CertificateBundleData if ($response->getHttpResponseCode() !== 200) { $this->logResponse('error', 'Failed to fetch certificate', $response); + throw new LetsEncryptClientException('Failed to fetch certificate.'); } diff --git a/src/Endpoints/Directory.php b/src/Endpoints/Directory.php index ef094e6..20b7833 100644 --- a/src/Endpoints/Directory.php +++ b/src/Endpoints/Directory.php @@ -15,6 +15,7 @@ public function all(): Response if ($response->getHttpResponseCode() >= 400) { $this->logResponse('error', 'Cannot get directory', $response); + throw new LetsEncryptClientException('Cannot get directory'); } diff --git a/src/Endpoints/DomainValidation.php b/src/Endpoints/DomainValidation.php index f181f26..b4dcb5f 100644 --- a/src/Endpoints/DomainValidation.php +++ b/src/Endpoints/DomainValidation.php @@ -76,12 +76,11 @@ public function getValidationData(array $challenges, ?AuthorizationChallengeEnum /** @throws \Rogierw\RwAcme\Exceptions\DomainValidationException */ public function start( - AccountData $accountData, - DomainValidationData $domainValidation, + AccountData $accountData, + DomainValidationData $domainValidation, AuthorizationChallengeEnum $authChallenge, - bool $localTest = true - ): Response - { + bool $localTest = true + ): Response { $this->client->logger('info', sprintf( 'Start %s challenge for %s', $authChallenge->value, diff --git a/src/Endpoints/Order.php b/src/Endpoints/Order.php index 6679b1d..b851d07 100644 --- a/src/Endpoints/Order.php +++ b/src/Endpoints/Order.php @@ -46,6 +46,7 @@ public function new(AccountData $accountData, array $domains): OrderData } $this->logResponse('error', 'Creating new order failed; bad response code.', $response, ['payload' => $payload]); + throw new LetsEncryptClientException('Creating new order failed; bad response code.'); } @@ -53,7 +54,8 @@ public function get(string $id): OrderData { $account = $this->client->account()->get(); - $orderUrl = sprintf('%s%s/%s', + $orderUrl = sprintf( + '%s%s/%s', $this->client->directory()->getOrder(), $account->id, $id, diff --git a/src/Exceptions/RateLimitException.php b/src/Exceptions/RateLimitException.php index e5259d8..c0018c9 100644 --- a/src/Exceptions/RateLimitException.php +++ b/src/Exceptions/RateLimitException.php @@ -4,5 +4,4 @@ class RateLimitException extends LetsEncryptClientException { - } diff --git a/src/Http/Response.php b/src/Http/Response.php index ccd6a0b..10333bf 100644 --- a/src/Http/Response.php +++ b/src/Http/Response.php @@ -5,12 +5,11 @@ class Response { public function __construct( - private readonly array $headers, - private readonly string $requestedUrl, - private readonly ?int $statusCode, + private readonly array $headers, + private readonly string $requestedUrl, + private readonly ?int $statusCode, private readonly array|string $body, - ) - { + ) { } public function getHeader(string $name, $default = null): mixed diff --git a/src/Interfaces/AcmeAccountInterface.php b/src/Interfaces/AcmeAccountInterface.php index 4adb699..32129d4 100644 --- a/src/Interfaces/AcmeAccountInterface.php +++ b/src/Interfaces/AcmeAccountInterface.php @@ -5,9 +5,14 @@ interface AcmeAccountInterface { public function setEmailAddress(string $emailAddress): self; + public function getEmailAddress(): string; + public function getPrivateKey(): string; + public function getPublicKey(): string; + public function exists(): bool; + public function generateNewKeys(string $keyType = 'RSA'): bool; } diff --git a/src/Support/JsonWebKey.php b/src/Support/JsonWebKey.php index 8e54801..dd0453f 100644 --- a/src/Support/JsonWebKey.php +++ b/src/Support/JsonWebKey.php @@ -8,8 +8,7 @@ class JsonWebKey { public static function compute( #[\SensitiveParameter] string $accountKey - ): array - { + ): array { $privateKey = openssl_pkey_get_private($accountKey); if ($privateKey === false) { diff --git a/src/Support/JsonWebSignature.php b/src/Support/JsonWebSignature.php index a628369..fae7f74 100644 --- a/src/Support/JsonWebSignature.php +++ b/src/Support/JsonWebSignature.php @@ -5,12 +5,11 @@ class JsonWebSignature { public static function generate( - array $payload, - string $url, - string $nonce, + array $payload, + string $url, + string $nonce, #[\SensitiveParameter] string $accountPrivateKey - ): array - { + ): array { $privateKey = openssl_pkey_get_private($accountPrivateKey); $protected = [ diff --git a/src/Support/KeyId.php b/src/Support/KeyId.php index 34cc041..bb7fea2 100644 --- a/src/Support/KeyId.php +++ b/src/Support/KeyId.php @@ -6,12 +6,11 @@ class KeyId { public static function generate( #[\SensitiveParameter] string $accountPrivateKey, - string $kid, - string $url, - string $nonce, - ?array $payload = null - ): array - { + string $kid, + string $url, + string $nonce, + ?array $payload = null + ): array { $privateKey = openssl_pkey_get_private($accountPrivateKey); $data = [ diff --git a/src/Support/LocalChallengeTest.php b/src/Support/LocalChallengeTest.php index 51ec09d..7c11463 100644 --- a/src/Support/LocalChallengeTest.php +++ b/src/Support/LocalChallengeTest.php @@ -3,7 +3,6 @@ namespace Rogierw\RwAcme\Support; use Rogierw\RwAcme\Exceptions\DomainValidationException; -use Rogierw\RwAcme\Http\Client; use Rogierw\RwAcme\Interfaces\HttpClientInterface; class LocalChallengeTest diff --git a/src/Support/LocalFileAccount.php b/src/Support/LocalFileAccount.php index b62a0ae..00c3da2 100644 --- a/src/Support/LocalFileAccount.php +++ b/src/Support/LocalFileAccount.php @@ -91,13 +91,13 @@ protected function getKey(string $type): string $filePath = $this->accountKeysPath.$this->getKeyName($type); if (!file_exists($filePath)) { - throw new LetsEncryptClientException(sprintf("[%s] File does not exist", $filePath)); + throw new LetsEncryptClientException(sprintf('[%s] File does not exist', $filePath)); } $content = file_get_contents($filePath); if ($content === false) { - throw new LetsEncryptClientException(sprintf("[%s] Failed to get contents of the file", $filePath)); + throw new LetsEncryptClientException(sprintf('[%s] Failed to get contents of the file', $filePath)); } return $content; From b2d254404145e77c99c89800391638648b8b30c7 Mon Sep 17 00:00:00 2001 From: Rogier van der Werf Date: Tue, 21 Nov 2023 11:28:04 +0100 Subject: [PATCH 11/15] update README.md --- README.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/README.md b/README.md index 3d1aaa0..003a6ea 100644 --- a/README.md +++ b/README.md @@ -27,7 +27,7 @@ Create an instance of `Rogierw\RwAcme\Api` client and provide it with a local ac ```php $localAccount = new \Rogierw\RwAcme\Support\LocalFileAccount(__DIR__.'/__account', 'test@example.com'); -$client = new Api($localAccount); +$client = new Api(localAccount: $localAccount); ``` You could also create a client and pass the local account data later: From f7957df58499e792a320c74cddfaf16f72898777 Mon Sep 17 00:00:00 2001 From: Rogier van der Werf Date: Tue, 21 Nov 2023 11:35:35 +0100 Subject: [PATCH 12/15] fix for the "undefined array key content-type" and "trim(): Passing null to parameter #1 ($string) of type string is deprecated" --- src/Endpoints/Nonce.php | 2 +- src/Http/Client.php | 13 +++++-------- src/Support/helpers.php | 9 +++++++++ 3 files changed, 15 insertions(+), 9 deletions(-) diff --git a/src/Endpoints/Nonce.php b/src/Endpoints/Nonce.php index 426937d..7dfc650 100644 --- a/src/Endpoints/Nonce.php +++ b/src/Endpoints/Nonce.php @@ -10,6 +10,6 @@ public function getNew(): string ->getHttpClient() ->head($this->client->directory()->newNonce()); - return trim($response->getHeader('replay-nonce')); + return trim($response->getHeader('replay_nonce')); } } diff --git a/src/Http/Client.php b/src/Http/Client.php index 69070aa..673f3dd 100644 --- a/src/Http/Client.php +++ b/src/Http/Client.php @@ -60,16 +60,13 @@ private function makeCurlRequest(string $httpVerb, string $fullUrl, array $heade $rawBody = mb_substr($rawResponse, $headerSize); $body = $rawBody; - if ( - $headers['content-type'] === 'application/json' || - $headers['content-type'] === 'application/problem+json' - ) { + $allHeaders = array_merge($headers, $this->parseRawHeaders($rawHeaders)); + + if (json_validate($rawBody)) { $body = json_decode($rawBody, true, 512, JSON_THROW_ON_ERROR); } - $parsedRawHeaders = $this->parseRawHeaders($rawHeaders); - - return new Response($headers, $parsedRawHeaders['url'] ?? '', $parsedRawHeaders['http_code'] ?? null, $body); + return new Response($allHeaders, $allHeaders['url'] ?? '', $allHeaders['http_code'] ?? null, $body); } private function attachRequestPayload(CurlHandle $curlHandle, array $data): void @@ -117,7 +114,7 @@ private function parseRawHeaders(string $rawHeaders): array [$name, $value] = explode(':', $header, 2); - $headersArr[strtolower($name)] = trim($value); + $headersArr[str_replace('-', '_', strtolower($name))] = trim($value); } return $headersArr; diff --git a/src/Support/helpers.php b/src/Support/helpers.php index d2b750c..db6e1cd 100644 --- a/src/Support/helpers.php +++ b/src/Support/helpers.php @@ -13,3 +13,12 @@ function value($value) return $value instanceof Closure ? $value() : $value; } } + +if (!function_exists('json_validate')) { + function json_validate(string $json): bool + { + json_decode($json); + + return json_last_error() === JSON_ERROR_NONE; + } +} \ No newline at end of file From d7dfb0a49aec8a81521931684efc6257c3f21075 Mon Sep 17 00:00:00 2001 From: StyleCI Bot Date: Tue, 21 Nov 2023 10:44:35 +0000 Subject: [PATCH 13/15] Apply fixes from StyleCI --- src/Support/helpers.php | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/Support/helpers.php b/src/Support/helpers.php index db6e1cd..ff678e8 100644 --- a/src/Support/helpers.php +++ b/src/Support/helpers.php @@ -21,4 +21,4 @@ function json_validate(string $json): bool return json_last_error() === JSON_ERROR_NONE; } -} \ No newline at end of file +} From 27521ac684626659e56a033ef9cebef2320cc86f Mon Sep 17 00:00:00 2001 From: Rogier Date: Tue, 21 Nov 2023 14:21:58 +0100 Subject: [PATCH 14/15] Update src/Http/Client.php Co-authored-by: Tristan --- src/Http/Client.php | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/Http/Client.php b/src/Http/Client.php index 673f3dd..e2afe9b 100644 --- a/src/Http/Client.php +++ b/src/Http/Client.php @@ -114,7 +114,7 @@ private function parseRawHeaders(string $rawHeaders): array [$name, $value] = explode(':', $header, 2); - $headersArr[str_replace('-', '_', strtolower($name))] = trim($value); + $headersArr[str_replace('_', '-', strtolower($name))] = trim($value); } return $headersArr; From 3af6cccd2777ca6f4bf1f24743d8c40fc8f38c0d Mon Sep 17 00:00:00 2001 From: Rogier Date: Tue, 21 Nov 2023 14:22:08 +0100 Subject: [PATCH 15/15] Update src/Endpoints/Nonce.php Co-authored-by: Tristan --- src/Endpoints/Nonce.php | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/Endpoints/Nonce.php b/src/Endpoints/Nonce.php index 7dfc650..426937d 100644 --- a/src/Endpoints/Nonce.php +++ b/src/Endpoints/Nonce.php @@ -10,6 +10,6 @@ public function getNew(): string ->getHttpClient() ->head($this->client->directory()->newNonce()); - return trim($response->getHeader('replay_nonce')); + return trim($response->getHeader('replay-nonce')); } }