Initial commit

This commit is contained in:
peterbakker
2020-02-13 08:55:12 +01:00
commit 5a35b6af35
13 changed files with 1817 additions and 0 deletions

5
.gitignore vendored Normal file
View File

@@ -0,0 +1,5 @@
.data
index.php
.idea
vendor
.store

13
LICENSE.md Normal file
View File

@@ -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.

140
README.md Normal file
View File

@@ -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());
```

30
composer.json Normal file
View File

@@ -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"
}
}

381
composer.lock generated Normal file
View File

@@ -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": []
}

629
src/Client.php Normal file
View File

@@ -0,0 +1,629 @@
<?php
namespace Afosto\LetsEncrypt;
use Afosto\LetsEncrypt\Data\Account;
use Afosto\LetsEncrypt\Data\Authorization;
use Afosto\LetsEncrypt\Data\Certificate;
use Afosto\LetsEncrypt\Data\Challenge;
use Afosto\LetsEncrypt\Data\Order;
use GuzzleHttp\Client as HttpClient;
use GuzzleHttp\Exception\ClientException;
use League\Flysystem\Filesystem;
use LEClient\LEFunctions;
use Psr\Http\Message\ResponseInterface;
class Client
{
/**
* Live url
*/
const DIRECTORY_LIVE = 'https://acme-v02.api.letsencrypt.org/directory';
/**
* Staging url
*/
const DIRECTORY_STAGING = 'https://acme-staging-v02.api.letsencrypt.org/directory';
/**
* Flag for production
*/
const MODE_LIVE = 'live';
/**
* Flag for staging
*/
const MODE_STAGING = 'staging';
/**
* New account directory
*/
const DIRECTORY_NEW_ACCOUNT = 'newAccount';
/**
* Nonce directory
*/
const DIRECTORY_NEW_NONCE = 'newNonce';
/**
* Order certificate directory
*/
const DIRECTORY_NEW_ORDER = 'newOrder';
/**
* Http validation
*/
const VALIDATION_HTTP = 'http-01';
/**
* @var string
*/
protected $nonce;
/**
* @var Account
*/
protected $account;
/**
* @var array
*/
protected $privateKeyDetails;
/**
* @var string
*/
protected $accountKey;
/**
* @var Filesystem
*/
protected $filesystem;
/**
* @var array
*/
protected $directories = [];
/**
* @var array
*/
protected $header = [];
/**
* @var string
*/
protected $digest;
/**
* @var HttpClient
*/
protected $httpClient;
/**
* @var array
*/
protected $config;
/**
* Client constructor.
*
* @param array $config
*
* @type string $mode The mode for ACME (production / staging)
* @type Filesystem $fs Filesystem for storage of static data
* @type string $basePath The base path for the filesystem (used to store account information and csr / keys
* @type string $username The acme username
* }
*/
public function __construct($config = [])
{
$this->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),
];
}
}

87
src/Data/Account.php Normal file
View File

@@ -0,0 +1,87 @@
<?php
namespace Afosto\LetsEncrypt\Data;
class Account
{
/**
* @var array
*/
protected $contact;
/**
* @var string
*/
protected $createdAt;
/**
* @var bool
*/
protected $isValid;
/**
* @var
*/
protected $initialIp;
/**
* @var string
*/
protected $accountURL;
public function __construct(
array $contact,
\DateTime $createdAt,
bool $isValid,
string $initialIp,
string $accountURL
) {
$this->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;
}
}

View File

@@ -0,0 +1,93 @@
<?php
namespace Afosto\LetsEncrypt\Data;
use Afosto\LetsEncrypt\Client;
class Authorization
{
/**
* @var string
*/
protected $domain;
/**
* @var \DateTime
*/
protected $expires;
/**
* @var Challenge[]
*/
protected $challenges = [];
/**
* @var string
*/
protected $digest;
public function __construct(string $domain, string $expires, string $digest)
{
$this->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;
}
}

76
src/Data/Certificate.php Normal file
View File

@@ -0,0 +1,76 @@
<?php
namespace Afosto\LetsEncrypt\Data;
use Afosto\LetsEncrypt\Helper;
class Certificate
{
/**
* @var string
*/
protected $privateKey;
/**
* @var string
*/
protected $certificate;
/**
* @var string
*/
protected $csr;
/**
* @var \DateTime
*/
protected $expiryDate;
/**
* Certificate constructor.
* @param $privateKey
* @param $csr
* @param $certificate
* @throws \Exception
*/
public function __construct($privateKey, $csr, $certificate)
{
$this->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;
}
}

83
src/Data/Challenge.php Normal file
View File

@@ -0,0 +1,83 @@
<?php
namespace Afosto\LetsEncrypt\Data;
class Challenge
{
/**
* @var string
*/
protected $authorizationURL;
/**
* @var string
*/
protected $type;
/**
* @var string
*/
protected $status;
/**
* @var string
*/
protected $url;
/**
* @var string
*/
protected $token;
/**
* Challenge constructor.
* @param string $authorizationURL
* @param string $type
* @param string $status
* @param string $url
* @param string $token
*/
public function __construct(string $authorizationURL, string $type, string $status, string $url, string $token)
{
$this->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;
}
}

40
src/Data/File.php Normal file
View File

@@ -0,0 +1,40 @@
<?php
namespace Afosto\LetsEncrypt\Data;
class File
{
/**
* @var string
*/
protected $filename;
/**
* @var string
*/
protected $contents;
public function __construct(string $filename, string $contents)
{
$this->contents = $contents;
$this->filename = $filename;
}
/**
* @return string
*/
public function getFilename(): string
{
return $this->filename;
}
/**
* @return string
*/
public function getContents(): string
{
return $this->contents;
}
}

102
src/Data/Order.php Normal file
View File

@@ -0,0 +1,102 @@
<?php
namespace Afosto\LetsEncrypt\Data;
class Order
{
/**
* @var string
*/
protected $url;
/**
* @var string
*/
protected $status;
/**
* @var \DateTime
*/
protected $expiresAt;
/**
* @var array
*/
protected $identifiers;
/**
* @var array
*/
protected $authorizations;
/**
* @var string
*/
protected $finalizeURL;
/**
* @var array
*/
protected $domains;
public function __construct(
array $domains,
string $url,
string $status,
string $expiresAt,
array $identifiers,
array $authorizations,
string $finalizeURL
) {
$this->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;
}
}

138
src/Helper.php Normal file
View File

@@ -0,0 +1,138 @@
<?php
namespace Afosto\LetsEncrypt;
use Afosto\LetsEncrypt\Data\Authorization;
use GuzzleHttp\Client as HttpClient;
use GuzzleHttp\Exception\ClientException;
class Helper
{
/**
* Formatter
* @param $pem
* @return false|string
*/
public static function toDer($pem)
{
$lines = explode(PHP_EOL, $pem);
$lines = array_slice($lines, 1, -1);
return base64_decode(implode('', $lines));
}
/**
* Return certificate expiry date
*
* @param $certificate
*
* @return \DateTime
* @throws \Exception
*/
public static function getCertExpiryDate($certificate): \DateTime
{
$info = openssl_x509_parse($certificate);
if ($info === false) {
throw new \Exception('Could not parse certificate');
}
$dateTime = new \DateTime();
$dateTime->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;
}
}