commit 5a35b6af35c3d9417fb6c4356aa87a0c4c7fa63f Author: peterbakker Date: Thu Feb 13 08:55:12 2020 +0100 Initial commit diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..abe6954 --- /dev/null +++ b/.gitignore @@ -0,0 +1,5 @@ +.data +index.php +.idea +vendor +.store \ No newline at end of file diff --git a/LICENSE.md b/LICENSE.md new file mode 100644 index 0000000..c37e5cd --- /dev/null +++ b/LICENSE.md @@ -0,0 +1,13 @@ +Copyright 2018 Afosto SaaS BV + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. \ No newline at end of file diff --git a/README.md b/README.md new file mode 100644 index 0000000..baea542 --- /dev/null +++ b/README.md @@ -0,0 +1,140 @@ +# yaac - Yet another ACME client + +Written in PHP, this client aims to be a decoupled LetsEncrypt client, based on ACME V2. + +## Decoupled from a filesystem or webserver + +In stead of, for example writing the certificate to the disk under an nginx configuration, this client just returns the +data (the certificate and private key). + +## Why + +Why whould I need this package? At Afosto we run our software in a multi tenant setup, as any other SaaS would do, and +therefore we cannot make use of the many clients that are already out there. + +Almost all clients are coupled to a type of webserver or a fixed (set of) domain(s). This package can be extremely +useful in case you need to dynamically fetch and install certificates. + + +## Requirements + +- PHP7+ +- openssl +- [Flysystem](http://flysystem.thephpleague.com/) (any adapter would do) - to store the Lets Encrypt account information + + +## Getting started + +Getting started is easy. First install the client, then you need to construct a flysystem filesystem, instantiate the +client and you can start requesting certificates. + +### Installation + +Installing this package is done easily with composer. +```bash +composer require afosto/yaac +``` + +### Instantiate the client + +To start the client you need 3 things; a username for your LetsEncrypt account, a bootstrapped Flysystem and you need to +decide whether you want to issue `Fake LE Intermediate X1` (staging: `MODE_STAGING`) or `Let's Encrypt Authority X3` +(live: `MODE_LIVE`, use for production) certificates. + +```php +use League\Flysystem\Filesystem; +use League\Flysystem\Adapter\Local; +use Afosto\LetsEncrypt\Client; + +//Prepare flysystem +$adapter = new Local('data'); +$filesystem = new Filesystem($adapter); + +//Construct the client +$client = new Client([ + 'username' => 'example@example.org', + 'fs' => $filesystem, + 'mode' => Client::MODE_STAGING, +]); +``` + +While you instantiate the client, when needed a new LetsEcrypt account is created and then agrees to the TOS. + + +### Create an order + +To start retrieving certificates, we need to create an order first. This is done as follows: + +```php +$order = $client->createOrder(['example.org', 'www.example.org']); +``` + +In the example above the primary domain is followed by a secondary domain(s). Make sure that for each domain you are +able to prove ownership. As a result the certificate will be valid for all provided domains. + +### Prove ownership + +Before you can obtain a certificate for a given domain you need to prove that you own the given domain(s). In this +example we will show you how to do this for http-01 validation (where serve specific content at a specific url on the +domain, like: `example.org/.well-known/acme-challenge/*`). + +Obtain the authorizations for order. For each domain supplied in the create order request an authorization is returned. + +```php +$authorizations = $client->authorize($order); +``` + + +You now have an array of `Authorization` objects. These have the challenges you can use (both `DNS` and `HTTP`) to +provide proof of ownership. + +Use the following example to get the HTTP validation going. First obtain the challenges, the next step is to make the +challenges accessible from +```php +foreach ($authorizations as $authorization) { + $challenge = $authorization->getHttpChallenge(); + + $file = $authorization->getFile($challenge); + file_put_contents($file->getFilename(), $file->getContents()); +} +``` + +Now that the challenges are in place and accessible through `example.org/.well-known/acme-challenge/*` we can request +validation. + +### Request validation + +Next step is to request validation of ownership. For each authorization (domain) we ask LetsEncrypt to verify the +challenge. + +```php +foreach ($authorizations as $authorization) { + $ok = $client->validate($authorization->getHttpChallenge(), 15); +} +``` + +The method above will perform 15 attempts to ask LetsEncrypt to validate the challenge (with 1 second intervals) and +retrieve an updated status (it might take Lets Encrypt a few seconds to validate the challenge). + +### Get the certificate + +Now to know if validation was successful, test if the order is ready as follows: + +```php +if ($client->isReady($order)) { + //The validation was successful. +} +``` + +We now know validation was completed and can obtain the certificate. This is done as follows: + +```php +$certificate = $client->getCertificate($order); +``` + +We now have the certificate, to store it on the filesystem: +```php +//Store the certificate and private key where you need it +file_put_contents('certificate.cert', $certificate->getCertificate()); +file_put_contents('private.key', $certificate->getPrivateKey()); +``` \ No newline at end of file diff --git a/composer.json b/composer.json new file mode 100644 index 0000000..f5bc176 --- /dev/null +++ b/composer.json @@ -0,0 +1,30 @@ +{ + "name": "afosto/yaac", + "description": "Yet Another ACME client", + "type": "package", + "keywords": [ + "afosto", + "lets", + "acme", + "acmev2", + "v2", + "encrypt" + ], + "homepage": "https://afosto.com", + "license": "Apache-2.0", + "authors": [ + { + "name": "Afosto Team", + "homepage": "https://afosto.com" + } + ], + "autoload": { + "psr-4": { + "Afosto\\LetsEncrypt\\": "./src" + } + }, + "require": { + "guzzlehttp/guzzle": "^6.3", + "league/flysystem": "^1.0" + } +} diff --git a/composer.lock b/composer.lock new file mode 100644 index 0000000..dc7c56f --- /dev/null +++ b/composer.lock @@ -0,0 +1,381 @@ +{ + "_readme": [ + "This file locks the dependencies of your project to a known state", + "Read more about it at https://getcomposer.org/doc/01-basic-usage.md#installing-dependencies", + "This file is @generated automatically" + ], + "content-hash": "11a6068373f0fe8d54154f4905027ef6", + "packages": [ + { + "name": "guzzlehttp/guzzle", + "version": "6.5.2", + "source": { + "type": "git", + "url": "https://github.com/guzzle/guzzle.git", + "reference": "43ece0e75098b7ecd8d13918293029e555a50f82" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/guzzle/guzzle/zipball/43ece0e75098b7ecd8d13918293029e555a50f82", + "reference": "43ece0e75098b7ecd8d13918293029e555a50f82", + "shasum": "" + }, + "require": { + "ext-json": "*", + "guzzlehttp/promises": "^1.0", + "guzzlehttp/psr7": "^1.6.1", + "php": ">=5.5" + }, + "require-dev": { + "ext-curl": "*", + "phpunit/phpunit": "^4.8.35 || ^5.7 || ^6.4 || ^7.0", + "psr/log": "^1.1" + }, + "suggest": { + "ext-intl": "Required for Internationalized Domain Name (IDN) support", + "psr/log": "Required for using the Log middleware" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-master": "6.5-dev" + } + }, + "autoload": { + "psr-4": { + "GuzzleHttp\\": "src/" + }, + "files": [ + "src/functions_include.php" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Michael Dowling", + "email": "mtdowling@gmail.com", + "homepage": "https://github.com/mtdowling" + } + ], + "description": "Guzzle is a PHP HTTP client library", + "homepage": "http://guzzlephp.org/", + "keywords": [ + "client", + "curl", + "framework", + "http", + "http client", + "rest", + "web service" + ], + "time": "2019-12-23T11:57:10+00:00" + }, + { + "name": "guzzlehttp/promises", + "version": "v1.3.1", + "source": { + "type": "git", + "url": "https://github.com/guzzle/promises.git", + "reference": "a59da6cf61d80060647ff4d3eb2c03a2bc694646" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/guzzle/promises/zipball/a59da6cf61d80060647ff4d3eb2c03a2bc694646", + "reference": "a59da6cf61d80060647ff4d3eb2c03a2bc694646", + "shasum": "" + }, + "require": { + "php": ">=5.5.0" + }, + "require-dev": { + "phpunit/phpunit": "^4.0" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-master": "1.4-dev" + } + }, + "autoload": { + "psr-4": { + "GuzzleHttp\\Promise\\": "src/" + }, + "files": [ + "src/functions_include.php" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Michael Dowling", + "email": "mtdowling@gmail.com", + "homepage": "https://github.com/mtdowling" + } + ], + "description": "Guzzle promises library", + "keywords": [ + "promise" + ], + "time": "2016-12-20T10:07:11+00:00" + }, + { + "name": "guzzlehttp/psr7", + "version": "1.6.1", + "source": { + "type": "git", + "url": "https://github.com/guzzle/psr7.git", + "reference": "239400de7a173fe9901b9ac7c06497751f00727a" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/guzzle/psr7/zipball/239400de7a173fe9901b9ac7c06497751f00727a", + "reference": "239400de7a173fe9901b9ac7c06497751f00727a", + "shasum": "" + }, + "require": { + "php": ">=5.4.0", + "psr/http-message": "~1.0", + "ralouphie/getallheaders": "^2.0.5 || ^3.0.0" + }, + "provide": { + "psr/http-message-implementation": "1.0" + }, + "require-dev": { + "ext-zlib": "*", + "phpunit/phpunit": "~4.8.36 || ^5.7.27 || ^6.5.8" + }, + "suggest": { + "zendframework/zend-httphandlerrunner": "Emit PSR-7 responses" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-master": "1.6-dev" + } + }, + "autoload": { + "psr-4": { + "GuzzleHttp\\Psr7\\": "src/" + }, + "files": [ + "src/functions_include.php" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Michael Dowling", + "email": "mtdowling@gmail.com", + "homepage": "https://github.com/mtdowling" + }, + { + "name": "Tobias Schultze", + "homepage": "https://github.com/Tobion" + } + ], + "description": "PSR-7 message implementation that also provides common utility methods", + "keywords": [ + "http", + "message", + "psr-7", + "request", + "response", + "stream", + "uri", + "url" + ], + "time": "2019-07-01T23:21:34+00:00" + }, + { + "name": "league/flysystem", + "version": "1.0.64", + "source": { + "type": "git", + "url": "https://github.com/thephpleague/flysystem.git", + "reference": "d13c43dbd4b791f815215959105a008515d1a2e0" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/thephpleague/flysystem/zipball/d13c43dbd4b791f815215959105a008515d1a2e0", + "reference": "d13c43dbd4b791f815215959105a008515d1a2e0", + "shasum": "" + }, + "require": { + "ext-fileinfo": "*", + "php": ">=5.5.9" + }, + "conflict": { + "league/flysystem-sftp": "<1.0.6" + }, + "require-dev": { + "phpspec/phpspec": "^3.4", + "phpunit/phpunit": "^5.7.26" + }, + "suggest": { + "ext-fileinfo": "Required for MimeType", + "ext-ftp": "Allows you to use FTP server storage", + "ext-openssl": "Allows you to use FTPS server storage", + "league/flysystem-aws-s3-v2": "Allows you to use S3 storage with AWS SDK v2", + "league/flysystem-aws-s3-v3": "Allows you to use S3 storage with AWS SDK v3", + "league/flysystem-azure": "Allows you to use Windows Azure Blob storage", + "league/flysystem-cached-adapter": "Flysystem adapter decorator for metadata caching", + "league/flysystem-eventable-filesystem": "Allows you to use EventableFilesystem", + "league/flysystem-rackspace": "Allows you to use Rackspace Cloud Files", + "league/flysystem-sftp": "Allows you to use SFTP server storage via phpseclib", + "league/flysystem-webdav": "Allows you to use WebDAV storage", + "league/flysystem-ziparchive": "Allows you to use ZipArchive adapter", + "spatie/flysystem-dropbox": "Allows you to use Dropbox storage", + "srmklive/flysystem-dropbox-v2": "Allows you to use Dropbox storage for PHP 5 applications" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-master": "1.1-dev" + } + }, + "autoload": { + "psr-4": { + "League\\Flysystem\\": "src/" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Frank de Jonge", + "email": "info@frenky.net" + } + ], + "description": "Filesystem abstraction: Many filesystems, one API.", + "keywords": [ + "Cloud Files", + "WebDAV", + "abstraction", + "aws", + "cloud", + "copy.com", + "dropbox", + "file systems", + "files", + "filesystem", + "filesystems", + "ftp", + "rackspace", + "remote", + "s3", + "sftp", + "storage" + ], + "time": "2020-02-05T18:14:17+00:00" + }, + { + "name": "psr/http-message", + "version": "1.0.1", + "source": { + "type": "git", + "url": "https://github.com/php-fig/http-message.git", + "reference": "f6561bf28d520154e4b0ec72be95418abe6d9363" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/php-fig/http-message/zipball/f6561bf28d520154e4b0ec72be95418abe6d9363", + "reference": "f6561bf28d520154e4b0ec72be95418abe6d9363", + "shasum": "" + }, + "require": { + "php": ">=5.3.0" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-master": "1.0.x-dev" + } + }, + "autoload": { + "psr-4": { + "Psr\\Http\\Message\\": "src/" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "PHP-FIG", + "homepage": "http://www.php-fig.org/" + } + ], + "description": "Common interface for HTTP messages", + "homepage": "https://github.com/php-fig/http-message", + "keywords": [ + "http", + "http-message", + "psr", + "psr-7", + "request", + "response" + ], + "time": "2016-08-06T14:39:51+00:00" + }, + { + "name": "ralouphie/getallheaders", + "version": "3.0.3", + "source": { + "type": "git", + "url": "https://github.com/ralouphie/getallheaders.git", + "reference": "120b605dfeb996808c31b6477290a714d356e822" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/ralouphie/getallheaders/zipball/120b605dfeb996808c31b6477290a714d356e822", + "reference": "120b605dfeb996808c31b6477290a714d356e822", + "shasum": "" + }, + "require": { + "php": ">=5.6" + }, + "require-dev": { + "php-coveralls/php-coveralls": "^2.1", + "phpunit/phpunit": "^5 || ^6.5" + }, + "type": "library", + "autoload": { + "files": [ + "src/getallheaders.php" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Ralph Khattar", + "email": "ralph.khattar@gmail.com" + } + ], + "description": "A polyfill for getallheaders.", + "time": "2019-03-08T08:55:37+00:00" + } + ], + "packages-dev": [], + "aliases": [], + "minimum-stability": "stable", + "stability-flags": [], + "prefer-stable": false, + "prefer-lowest": false, + "platform": [], + "platform-dev": [] +} diff --git a/src/Client.php b/src/Client.php new file mode 100644 index 0000000..550d4bc --- /dev/null +++ b/src/Client.php @@ -0,0 +1,629 @@ +config = $config; + $this->httpClient = new HttpClient([ + 'base_uri' => ( + ($this->getOption('mode', self::MODE_LIVE) == self::MODE_LIVE) ? + self::DIRECTORY_LIVE : self::DIRECTORY_STAGING), + ]); + + if ($this->getOption('fs', false)) { + $this->filesystem = $this->getOption('fs'); + } else { + throw new \LogicException('No filesystem option supplied'); + } + + if ($this->getOption('username', false) === false) { + throw new \LogicException('Username not provided'); + } + + $this->init(); + } + + + /** + * Get an existing order by ID + * + * @param $id + * @return Order + * @throws \Exception + */ + public function getOrder($id): Order + { + $url = str_replace('new-order', 'order', $this->getUrl(self::DIRECTORY_NEW_ORDER)); + $url = $url . '/' . $this->getAccount()->getId() . '/' . $id; + $response = $this->request($url, $this->signPayloadKid(null, $url)); + $data = json_decode((string)$response->getBody(), true); + + $domains = []; + foreach ($data['identifiers'] as $identifier) { + $domains[] = $identifier['value']; + } + + return new Order( + $domains, + $response->getHeaderLine('location'), + $data['status'], + $data['expires'], + $data['identifiers'], + $data['authorizations'], + $data['finalize'] + ); + } + + /** + * Get ready status for order + * + * @param Order $order + * @return bool + * @throws \Exception + */ + public function isReady(Order $order): bool + { + $order = $this->getOrder($order->getId()); + return $order->getStatus() == 'ready'; + } + + + /** + * Create a new order + * + * @param array $domains + * @return Order + * @throws \Exception + */ + public function createOrder(array $domains): Order + { + $identifiers = []; + foreach ($domains as $domain) { + $identifiers[] = + [ + 'type' => 'dns', + 'value' => $domain, + ]; + } + + $url = $this->getUrl(self::DIRECTORY_NEW_ORDER); + $response = $this->request($url, $this->signPayloadKid( + [ + 'identifiers' => $identifiers, + ], + $url + )); + + $data = json_decode((string)$response->getBody(), true); + $order = new Order( + $domains, + $response->getHeaderLine('location'), + $data['status'], + $data['expires'], + $data['identifiers'], + $data['authorizations'], + $data['finalize'] + ); + + + return $order; + } + + /** + * Obtain authorizations + * + * @param Order $order + * @return array|Authorization[] + * @throws \Exception + */ + public function authorize(Order $order): array + { + $authorizations = []; + foreach ($order->getAuthorizationURLs() as $authorizationURL) { + $response = $this->request( + $authorizationURL, + $this->signPayloadKid(null, $authorizationURL) + ); + $data = json_decode((string)$response->getBody(), true); + $authorization = new Authorization($data['identifier']['value'], $data['expires'], $this->getDigest()); + + foreach ($data['challenges'] as $challengeData) { + $challenge = new Challenge( + $authorizationURL, + $challengeData['type'], + $challengeData['status'], + $challengeData['url'], + $challengeData['token'] + ); + $authorization->addChallenge($challenge); + } + $authorizations[] = $authorization; + } + + return $authorizations; + } + + /** + * Validate a challenge + * + * @param Challenge $challenge + * @param int $maxAttempts + * @return bool + * @throws \Exception + */ + public function validate(Challenge $challenge, $maxAttempts = 15): bool + { + $this->request( + $challenge->getUrl(), + $this->signPayloadKid([ + 'keyAuthorization' => $challenge->getToken() . '.' . $this->getDigest() + ], $challenge->getUrl()) + ); + + $data = []; + do { + $maxAttempts--; + $response = $this->request( + $challenge->getAuthorizationURL(), + $this->signPayloadKid(null, $challenge->getAuthorizationURL()) + ); + $data = json_decode((string)$response->getBody(), true); + sleep(1); + } while ($maxAttempts < 0 && $data['status'] == 'pending'); + + return (isset($data['status']) && $data['status'] == 'ready'); + } + + /** + * Return a certificate + * + * @param Order $order + * @return Certificate + * @throws \Exception + */ + public function getCertificate(Order $order): Certificate + { + $privateKey = Helper::getNewKey(); + $csr = Helper::getCsr($order->getDomains(), $privateKey); + $der = Helper::toDer($csr); + + $response = $this->request( + $order->getFinalizeURL(), + $this->signPayloadKid( + ['csr' => Helper::toSafeString($der)], + $order->getFinalizeURL() + ) + ); + + $data = json_decode((string)$response->getBody(), true); + $certificateResponse = $this->request( + $data['certificate'], + $this->signPayloadKid(null, $data['certificate']) + ); + $certificate = $str = preg_replace('/^[ \t]*[\r\n]+/m', '', (string)$certificateResponse->getBody()); + return new Certificate($privateKey, $csr, $certificate); + } + + + /** + * Return LE account information + * + * @return Account + * @throws \Exception + */ + public function getAccount(): Account + { + $response = $this->request( + $this->getUrl(self::DIRECTORY_NEW_ACCOUNT), + $this->signPayloadJWK( + [ + 'onlyReturnExisting' => true, + ], + $this->getUrl(self::DIRECTORY_NEW_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'), $data['initialIp'], $accountURL); + } + + /** + * Initialize the client + */ + protected function init() + { + //Load the directories from the LE api + $response = $this->httpClient->get('/directory'); + $result = \GuzzleHttp\json_decode((string)$response->getBody(), true); + $this->directories = $result; + + //Prepare LE account + $this->loadKeys(); + $this->tosAgree(); + $this->account = $this->getAccount(); + } + + /** + * Load the keys in memory + * + * @throws \League\Flysystem\FileExistsException + * @throws \League\Flysystem\FileNotFoundException + */ + protected function loadKeys() + { + //Make sure a private key is in place + if ($this->getFilesystem()->has($this->getPath('account.pem')) === false) { + $this->getFilesystem()->write($this->getPath('account.pem'), Helper::getNewKey()); + } + $privateKey = $this->getFilesystem()->read($this->getPath('account.pem')); + $privateKey = openssl_pkey_get_private($privateKey); + $this->privateKeyDetails = openssl_pkey_get_details($privateKey); + } + + /** + * Agree to the terms of service + * + * @throws \Exception + */ + protected function tosAgree() + { + $this->request( + $this->getUrl(self::DIRECTORY_NEW_ACCOUNT), + $this->signPayloadJWK( + [ + 'contact' => [ + 'mailto:' . $this->getOption('username'), + ], + 'termsOfServiceAgreed' => true, + ], + $this->getUrl(self::DIRECTORY_NEW_ACCOUNT) + ) + ); + } + + /** + * Get a formatted path + * + * @param null $path + * @return string + */ + 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); + } + + /** + * @return Filesystem + */ + protected function getFilesystem(): Filesystem + { + return $this->filesystem; + } + + /** + * Get a defined option + * + * @param $key + * @param null $default + * + * @return mixed|null + */ + protected function getOption($key, $default = null) + { + if (isset($this->config[$key])) { + return $this->config[$key]; + } + + return $default; + } + + /** + * Get key fingerprint + * + * @return string + * @throws \Exception + */ + protected function getDigest(): string + { + if ($this->digest === null) { + $this->digest = Helper::toSafeString(hash('sha256', json_encode($this->getJWKHeader()), true)); + } + + return $this->digest; + } + + /** + * Send a request to the LE API + * + * @param $url + * @param array $payload + * @param string $method + * @return ResponseInterface + */ + protected function request($url, $payload = [], $method = 'POST'): ResponseInterface + { + try { + $response = $this->httpClient->request($method, $url, [ + 'json' => $payload, + 'headers' => [ + 'Content-Type' => 'application/jose+json', + ] + ]); + $this->nonce = $response->getHeaderLine('replay-nonce'); + } catch (ClientException $e) { + throw $e; + } + + return $response; + } + + /** + * Get the LE directory path + * + * @param $directory + * + * @return mixed + * @throws \Exception + */ + protected function getUrl($directory): string + { + if (isset($this->directories[$directory])) { + return $this->directories[$directory]; + } + + throw new \Exception('Invalid directory: ' . $directory . ' not listed'); + } + + + /** + * Get the key + * + * @return bool|resource|string + * @throws \Exception + */ + protected function getAccountKey() + { + if ($this->accountKey === null) { + $this->accountKey = openssl_pkey_get_private($this->getFilesystem() + ->read($this->getPath('account.pem'))); + } + + if ($this->accountKey === false) { + throw new \Exception('Invalid account key'); + } + + return $this->accountKey; + } + + /** + * Get the header + * + * @return array + * @throws \Exception + */ + protected function getJWKHeader(): array + { + return [ + 'e' => Helper::toSafeString(Helper::getKeyDetails($this->getAccountKey())['rsa']['e']), + 'kty' => 'RSA', + 'n' => Helper::toSafeString(Helper::getKeyDetails($this->getAccountKey())['rsa']['n']), + ]; + } + + /** + * Get JWK envelope + * + * @param $url + * @return array + * @throws \Exception + */ + protected function getJWK($url): array + { + //Require a nonce to be available + if ($this->nonce === null) { + $response = $this->httpClient->head($this->directories[self::DIRECTORY_NEW_NONCE]); + $this->nonce = $response->getHeaderLine('replay-nonce'); + } + return [ + 'alg' => 'RS256', + 'jwk' => $this->getJWKHeader(), + 'nonce' => $this->nonce, + 'url' => $url + ]; + } + + /** + * Get KID envelope + * + * @param $url + * @param $kid + * @return array + */ + protected function getKID($url): array + { + $response = $this->httpClient->head($this->directories[self::DIRECTORY_NEW_NONCE]); + $nonce = $response->getHeaderLine('replay-nonce'); + + return [ + "alg" => "RS256", + "kid" => $this->account->getAccountURL(), + "nonce" => $nonce, + "url" => $url + ]; + } + + /** + * Transform the payload to the JWS format + * + * @param $payload + * @param $url + * @return array + * @throws \Exception + */ + protected function signPayloadJWK($payload, $url): array + { + $payload = is_array($payload) ? str_replace('\\/', '/', json_encode($payload)) : ''; + $payload = Helper::toSafeString($payload); + $protected = Helper::toSafeString(json_encode($this->getJWK($url))); + + $result = openssl_sign($protected . '.' . $payload, $signature, $this->getAccountKey(), "SHA256"); + + if ($result === false) { + throw new \Exception('Could not sign'); + } + + return [ + 'protected' => $protected, + 'payload' => $payload, + 'signature' => Helper::toSafeString($signature), + ]; + } + + /** + * Transform the payload to the KID format + * + * @param $payload + * @param $url + * @return array + * @throws \Exception + */ + protected function signPayloadKid($payload, $url): array + { + $payload = is_array($payload) ? str_replace('\\/', '/', json_encode($payload)) : ''; + $payload = Helper::toSafeString($payload); + $protected = Helper::toSafeString(json_encode($this->getKID($url))); + + $result = openssl_sign($protected . '.' . $payload, $signature, $this->getAccountKey(), "SHA256"); + if ($result === false) { + throw new \Exception('Could not sign'); + } + + return [ + 'protected' => $protected, + 'payload' => $payload, + 'signature' => Helper::toSafeString($signature), + ]; + } +} diff --git a/src/Data/Account.php b/src/Data/Account.php new file mode 100644 index 0000000..984cb4d --- /dev/null +++ b/src/Data/Account.php @@ -0,0 +1,87 @@ +initialIp = $initialIp; + $this->contact = $contact; + $this->createdAt = $createdAt; + $this->isValid = $isValid; + $this->accountURL = $accountURL; + } + + public function getId(): string + { + return substr($this->accountURL, strrpos($this->accountURL, '/') + 1); + } + + public function getCreatedAt(): \DateTime + { + return $this->createdAt; + } + + + public function getAccountURL(): string + { + return $this->accountURL; + } + + /** + * @return array + */ + public function getContact(): array + { + return $this->contact; + } + + /** + * @return string + */ + public function getInitialIp(): string + { + return $this->initialIp; + } + + /** + * @return bool + */ + public function isValid(): bool + { + return $this->isValid; + } +} diff --git a/src/Data/Authorization.php b/src/Data/Authorization.php new file mode 100644 index 0000000..e116efb --- /dev/null +++ b/src/Data/Authorization.php @@ -0,0 +1,93 @@ +domain = $domain; + $this->expires = (new \DateTime())->setTimestamp(strtotime($expires)); + $this->digest = $digest; + } + + public function addChallenge(Challenge $challenge) + { + $this->challenges[] = $challenge; + } + + /** + * @return array + */ + public function getDomain(): string + { + return $this->domain; + } + + + /** + * @return \DateTime + */ + public function getExpires(): \DateTime + { + return $this->expires; + } + + /** + * @return Challenge[] + */ + public function getChallenges(): array + { + return $this->challenges; + } + + /** + * @return Challenge|bool + */ + public function getHttpChallenge() + { + foreach ($this->getChallenges() as $challenge) { + if ($challenge->getType() == Client::VALIDATION_HTTP) { + return $challenge; + } + } + + return false; + } + + /** + * @param Challenge $challenge + * @return File|bool + */ + public function getFile(Challenge $challenge) + { + if ($challenge->getType() == Client::VALIDATION_HTTP) { + $file = new File($challenge->getToken(), $challenge->getToken() . '.' . $this->digest); + return $file; + } + return false; + } +} diff --git a/src/Data/Certificate.php b/src/Data/Certificate.php new file mode 100644 index 0000000..539365c --- /dev/null +++ b/src/Data/Certificate.php @@ -0,0 +1,76 @@ +privateKey = $privateKey; + $this->csr = $csr; + $this->certificate = $certificate; + $this->expiryDate = Helper::getCertExpiryDate($certificate); + } + + /** + * @return string + */ + public function getCsr(): string + { + return $this->csr; + } + + /** + * @return \DateTime + */ + public function getExpiryDate(): \DateTime + { + return $this->expiryDate; + } + + /** + * @return string + */ + public function getCertificate(): string + { + return $this->certificate; + } + + /** + * @return string + */ + public function getPrivateKey(): string + { + return $this->privateKey; + } +} diff --git a/src/Data/Challenge.php b/src/Data/Challenge.php new file mode 100644 index 0000000..6910bd1 --- /dev/null +++ b/src/Data/Challenge.php @@ -0,0 +1,83 @@ +authorizationURL = $authorizationURL; + $this->type = $type; + $this->status = $status; + $this->url = $url; + $this->token = $token; + } + + /** + * @return string + */ + public function getUrl(): string + { + return $this->url; + } + + /** + * @return string + */ + public function getType(): string + { + return $this->type; + } + + /** + * @return string + */ + public function getToken(): string + { + return $this->token; + } + + public function getStatus(): string + { + return $this->status; + } + + public function getAuthorizationURL(): string + { + return $this->authorizationURL; + } +} diff --git a/src/Data/File.php b/src/Data/File.php new file mode 100644 index 0000000..d7ec816 --- /dev/null +++ b/src/Data/File.php @@ -0,0 +1,40 @@ +contents = $contents; + $this->filename = $filename; + } + + /** + * @return string + */ + public function getFilename(): string + { + return $this->filename; + } + + /** + * @return string + */ + public function getContents(): string + { + return $this->contents; + } +} diff --git a/src/Data/Order.php b/src/Data/Order.php new file mode 100644 index 0000000..47c6eae --- /dev/null +++ b/src/Data/Order.php @@ -0,0 +1,102 @@ +domains = $domains; + $this->url = $url; + $this->status = $status; + $this->expiresAt = (new \DateTime())->setTimestamp(strtotime($expiresAt)); + $this->identifiers = $identifiers; + $this->authorizations = $authorizations; + $this->finalizeURL = $finalizeURL; + } + + public function getId(): string + { + return substr($this->url, strrpos($this->url, '/') + 1); + } + + public function getURL(): string + { + return $this->url; + } + + public function getAuthorizationURLs(): array + { + return $this->authorizations; + } + + public function getStatus(): string + { + return $this->status; + } + + public function getExpiresAt(): \DateTime + { + return $this->expiresAt; + } + + public function getIdentifiers(): array + { + return $this->identifiers; + } + + public function getFinalizeURL(): string + { + return $this->finalizeURL; + } + + public function getDomains(): array + { + return $this->domains; + } +} diff --git a/src/Helper.php b/src/Helper.php new file mode 100644 index 0000000..a969724 --- /dev/null +++ b/src/Helper.php @@ -0,0 +1,138 @@ +setTimestamp($info['validTo_time_t']); + + return $dateTime; + } + + /** + * Get a new key + * + * @return string + */ + public static function getNewKey(): string + { + + $key = openssl_pkey_new([ + 'private_key_bits' => 4096, + 'private_key_type' => OPENSSL_KEYTYPE_RSA, + ]); + openssl_pkey_export($key, $pem); + + return $pem; + } + + /** + * Get a new CSR + * + * @param array $domains + * @param $key + * + * @return string + * @throws \Exception + */ + public static function getCsr(array $domains, $key): string + { + $primaryDomain = current(($domains)); + $config = [ + '[req]', + 'distinguished_name=req_distinguished_name', + '[req_distinguished_name]', + '[v3_req]', + '[v3_ca]', + '[SAN]', + 'subjectAltName=' . implode(',', array_map(function ($domain) { + return 'DNS:' . $domain; + }, $domains)), + ]; + + $fn = tempnam(sys_get_temp_dir(), md5(microtime(true))); + file_put_contents($fn, implode("\n", $config)); + $csr = openssl_csr_new([ + 'countryName' => 'NL', + 'commonName' => $primaryDomain, + ], $key, [ + 'config' => $fn, + 'req_extensions' => 'SAN', + 'digest_alg' => 'sha512', + ]); + unlink($fn); + + if ($csr === false) { + throw new \Exception('Could not create a CSR'); + } + + if (openssl_csr_export($csr, $result) == false) { + throw new \Exception('CRS export failed'); + } + + $result = trim($result); + + return $result; + } + + /** + * Make a safe base64 string + * + * @param $data + * + * @return string + */ + public static function toSafeString($data): string + { + return rtrim(strtr(base64_encode($data), '+/', '-_'), '='); + } + + /** + * Get the key information + * + * @return array + * @throws \Exception + */ + public static function getKeyDetails($key): array + { + $accountDetails = openssl_pkey_get_details($key); + if ($accountDetails === false) { + throw new \Exception('Could not load account details'); + } + + return $accountDetails; + } +}