first commit

This commit is contained in:
2024-11-04 20:48:19 +01:00
commit 2fa33a3be9
7968 changed files with 2313063 additions and 0 deletions

View File

@@ -0,0 +1 @@
<?php // You don't belong here. ?>

View File

@@ -0,0 +1,19 @@
MIT License
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE.

View File

@@ -0,0 +1,119 @@
# le-acme2-php
LetsEncrypt client library for ACME v2 written in PHP.
This library is inspired by [yourivw/LEClient](https://github.com/yourivw/LEClient), completely rewritten and enhanced with some new features:
- Support for Composer autoload (including separated Namespaces)
- Automatic renewal process
- Managed HTTP authentication process
- Response caching mechanism
- Prevents blocking while waiting for server results
- Optional certificate feature "OCSP Must-Staple"
- Optional set a preferred chain
The aim of this client is to make an easy-to-use and integrated solution to create a LetsEncrypt-issued SSL/TLS certificate with PHP.
You have the possibility to use the HTTP authentication:
You need to be able to redirect specific requests (see below)
You have also the possibility to use DNS authentication:
You need to be able to set dynamic DNS configurations.
Wildcard certificates can only be requested by using the dns authentication.
## Current version
Tested with LetsEncrypt staging and production servers.
[Transitioning to ISRG's Root](https://letsencrypt.org/2019/04/15/transitioning-to-isrg-root.html):
This library supports it to set a preferred chain in `Order::setPreferredChain($issuerCN))`.
If the preferred chain is not set or set to IdenTrusts chain,
this library will try to use the IdenTrusts chain as long as possible.
Please see: https://letsencrypt.org/docs/dst-root-ca-x3-expiration-september-2021/
## Prerequisites
The minimum required PHP version is 7.3.
This client also depends on cURL and OpenSSL.
## Getting Started
Install via composer:
```
composer require fbett/le_acme2
```
Also have a look at the [LetsEncrypt documentation](https://letsencrypt.org/docs/) for more information and documentation on LetsEncrypt and ACME.
## Example Integration
- Create a working directory.
Warning: This directory will also include private keys, so i suggest to place this directory somewhere not in the root document path of the web server.
Additionally this directory should be protected to be read from other web server users.
```
mkdir /etc/ssl/le-storage/
chown root:root /etc/ssl/le-storage
chmod 0600 /etc/ssl/le-storage
```
- (HTTP authorization only) Create a directory for the acme challenges. It must be reachable by http/https.
```
mkdir /var/www/acme-challenges
```
- (HTTP authorization only) Redirect specific requests to your acme-challenges directory
Example apache virtual host configuration:
```
<VirtualHost ...>
<IfModule mod_rewrite.c>
RewriteEngine On
RewriteCond %{HTTPS} off
RewriteRule \.well-known/acme-challenge/(.*)$ https://your-domain.com/path/to/acme-challenges/$1 [R=302,L]
</IfModule>
</VirtualHost>
```
- (DNS authorization only) Set the DNS configuration
If `DNSWriter::write(...)` is called, set the DNS configuration like described in:
[https://letsencrypt.org/docs/challenge-types/#dns-01-challenge](https://letsencrypt.org/docs/challenge-types/#dns-01-challenge)
(By adding the digest as a TXT record for the subdomain '_acme-challenge'.)
- Use the certificate bundle, if the certificate is issued:
```
if($order->isCertificateBundleAvailable()) {
$bundle = $order->getCertificateBundle();
$pathToPrivateKey = $bundle->path . $bundle->private;
$pathToCertificate = $bundle->path . $bundle->certificate;
$pathToIntermediate = $bundle->path . $bundle->intermediate;
$order->enableAutoRenewal(); // If the date of expiration is closer than thirty days, the order will automatically start the renewal process.
}
```
If a certificate is renewed, the path will also change.
My integrated workflow is the following:
- User enables SSL to a specific domain in my control panel
- The cronjob of this control panel will detect these changes and tries to create or get an order like in the sample.
- The cronjob will fetch the information within the certificate bundle, if the certificate bundle is ready (mostly on the second run for challenge type HTTP and on the third run for challenge type DNS)
- The cronjob will also build the Apache virtual host files and will restart the Apache2 service, if the new config file is different.
Please take a look on the Samples for a full sample workflow.
## License
This project is licensed under the MIT License - see the [LICENSE.md](LICENSE.md) file for details.

View File

@@ -0,0 +1,25 @@
{
"name": "fbett/le_acme2",
"description": "Letsencrypt PHP ACME v2 client",
"homepage": "https://github.com/fbett/le-acme2-php",
"version": "1.5.6",
"license": "MIT",
"authors": [
{
"name": "Fabian Bett",
"homepage": "https://www.bett-ingenieure.de",
"role": "Developer"
}
],
"autoload": {
"psr-0": {
"LE_ACME2": "src/"
}
},
"require": {
"php": ">=7.3",
"ext-curl": "*",
"ext-openssl": "*",
"ext-json": "*"
}
}

View File

@@ -0,0 +1,25 @@
<?xml version="1.0" encoding="UTF-8"?>
<phpunit xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
backupGlobals="false"
backupStaticAttributes="false"
colors="true"
forceCoversAnnotation="true"
convertErrorsToExceptions="true"
convertNoticesToExceptions="true"
convertWarningsToExceptions="true"
processIsolation="false"
stopOnFailure="true"
bootstrap="../../autoload.php"
xsi:noNamespaceSchemaLocation="https://schema.phpunit.de/9.3/phpunit.xsd"
>
<coverage processUncoveredFiles="true">
<include>
<directory suffix=".php">./src/LE_ACME2</directory>
</include>
</coverage>
<testsuites>
<testsuite name="LE_ACME2 Test Suite">
<directory>./src/LE_ACME2Tests</directory>
</testsuite>
</testsuites>
</phpunit>

View File

@@ -0,0 +1,96 @@
<?php
namespace LE_ACME2;
defined('ABSPATH') or die();
use LE_ACME2\Connector\Connector;
abstract class AbstractKeyValuable {
const KEY_TYPE_RSA = "RSA";
const KEY_TYPE_EC = "EC";
protected $_identifier;
protected static $_directoryPath = null;
public static function setCommonKeyDirectoryPath(string $directoryPath) {
if(!file_exists($directoryPath)) {
throw new \RuntimeException('Common Key Directory Path does not exist');
}
self::$_directoryPath = realpath($directoryPath) . DIRECTORY_SEPARATOR;
}
public static function getCommonKeyDirectoryPath() : ?string {
return self::$_directoryPath;
}
protected function _getKeyDirectoryPath(string $appendix = '') : string {
return self::$_directoryPath . $this->_identifier . $appendix . DIRECTORY_SEPARATOR;
}
public function getKeyDirectoryPath() : string {
return $this->_getKeyDirectoryPath('');
}
protected function _initKeyDirectory(string $keyType = self::KEY_TYPE_RSA, bool $ignoreIfKeysExist = false) {
if(!file_exists($this->getKeyDirectoryPath())) {
mkdir($this->getKeyDirectoryPath());
}
if(!$ignoreIfKeysExist && (
file_exists($this->getKeyDirectoryPath() . 'private.pem') ||
file_exists($this->getKeyDirectoryPath() . 'public.pem')
)
) {
throw new \RuntimeException(
'Keys exist already. Exists the ' . get_class($this) . ' already?' . PHP_EOL .
'Path: ' . $this->getKeyDirectoryPath()
);
}
if($keyType == self::KEY_TYPE_RSA) {
Utilities\KeyGenerator::RSA(
$this->getKeyDirectoryPath(),
'private.pem',
'public.pem'
);
} else if($keyType == self::KEY_TYPE_EC) {
Utilities\KeyGenerator::EC(
$this->getKeyDirectoryPath(),
'private.pem',
'public.pem'
);
} else {
throw new \RuntimeException('Key type "' . $keyType . '" not supported.');
}
}
protected function _clearKeyDirectory() {
if(file_exists($this->getKeyDirectoryPath() . 'private.pem')) {
unlink($this->getKeyDirectoryPath() . 'private.pem');
}
if(file_exists($this->getKeyDirectoryPath() . 'public.pem')) {
unlink($this->getKeyDirectoryPath() . 'public.pem');
}
}
protected function _getAccountIdentifier(Account $account) : string {
$staging = Connector::getInstance()->isUsingStagingServer();
return 'account_' . ($staging ? 'staging_' : 'live_') . $account->getEmail();
}
}

View File

@@ -0,0 +1,163 @@
<?php
namespace LE_ACME2;
defined('ABSPATH') or die();
use LE_ACME2\Request;
use LE_ACME2\Response;
use LE_ACME2\Utilities;
use LE_ACME2\Exception;
class Account extends AbstractKeyValuable {
private $_email = NULL;
public function __construct(string $email) {
$this->_setEmail($email);
Utilities\Logger::getInstance()->add(
Utilities\Logger::LEVEL_INFO,
get_class() . '::' . __FUNCTION__ . ' email: "' . $email . '"'
);
Utilities\Logger::getInstance()->add(
Utilities\Logger::LEVEL_DEBUG,
get_class() . '::' . __FUNCTION__ . ' path: ' . $this->getKeyDirectoryPath()
);
}
private function _setEmail(string $email) {
$this->_email = $email;
$this->_identifier = $this->_getAccountIdentifier($this);
}
public function getEmail() : string {
return $this->_email;
}
/**
* @param string $email
* @return Account|null
* @throws Exception\AbstractException
*/
public static function create(string $email) : Account {
$account = new self($email);
$account->_initKeyDirectory();
$request = new Request\Account\Create($account);
try {
$response = $request->getResponse();
Cache\AccountResponse::getInstance()->set($account, $response);
return $account;
} catch(Exception\AbstractException $e) {
$account->_clearKeyDirectory();
throw $e;
}
}
public static function exists(string $email) : bool {
$account = new self($email);
return file_exists($account->getKeyDirectoryPath()) &&
file_exists($account->getKeyDirectoryPath() . 'private.pem') &&
file_exists($account->getKeyDirectoryPath() . 'public.pem');
}
public static function get(string $email) : Account {
$account = new self($email);
if(!self::exists($email))
throw new \RuntimeException('Keys not found - does this account exist?');
return $account;
}
/**
* @return Response\AbstractResponse|Response\Account\GetData
* @throws Exception\InvalidResponse
* @throws Exception\RateLimitReached
*/
public function getData() : Response\Account\GetData {
$request = new Request\Account\GetData($this);
return $request->getResponse();
}
/**
* @param string $email
* @return bool
* @throws Exception\RateLimitReached
*/
public function update(string $email) : bool {
$request = new Request\Account\Update($this, $email);
try {
/* $response = */ $request->getResponse();
$previousKeyDirectoryPath = $this->getKeyDirectoryPath();
$this->_setEmail($email);
if($previousKeyDirectoryPath != $this->getKeyDirectoryPath())
rename($previousKeyDirectoryPath, $this->getKeyDirectoryPath());
return true;
} catch(Exception\InvalidResponse $e) {
return false;
}
}
/**
* @return bool
* @throws Exception\RateLimitReached
*/
public function changeKeys() : bool {
Utilities\KeyGenerator::RSA($this->getKeyDirectoryPath(), 'private-replacement.pem', 'public-replacement.pem');
$request = new Request\Account\ChangeKeys($this);
try {
/* $response = */ $request->getResponse();
unlink($this->getKeyDirectoryPath() . 'private.pem');
unlink($this->getKeyDirectoryPath() . 'public.pem');
rename($this->getKeyDirectoryPath() . 'private-replacement.pem', $this->getKeyDirectoryPath() . 'private.pem');
rename($this->getKeyDirectoryPath() . 'public-replacement.pem', $this->getKeyDirectoryPath() . 'public.pem');
return true;
} catch(Exception\InvalidResponse $e) {
return false;
}
}
/**
* @return bool
* @throws Exception\RateLimitReached
*/
public function deactivate() : bool {
$request = new Request\Account\Deactivate($this);
try {
/* $response = */ $request->getResponse();
return true;
} catch(Exception\InvalidResponse $e) {
return false;
}
}
}

View File

@@ -0,0 +1,174 @@
<?php
namespace LE_ACME2\Authorizer;
defined('ABSPATH') or die();
use LE_ACME2\Request;
use LE_ACME2\Response;
use LE_ACME2\Cache;
use LE_ACME2\Utilities;
use LE_ACME2\Exception;
use LE_ACME2\Account;
use LE_ACME2\Order;
abstract class AbstractAuthorizer {
protected $_account;
protected $_order;
/**
* AbstractAuthorizer constructor.
*
* @param Account $account
* @param Order $order
*
* @throws Exception\InvalidResponse
* @throws Exception\RateLimitReached
* @throws Exception\ExpiredAuthorization
*/
public function __construct(Account $account, Order $order) {
$this->_account = $account;
$this->_order = $order;
$this->_fetchAuthorizationResponses();
}
/** @var Response\Authorization\Get[] $_authorizationResponses */
protected $_authorizationResponses = [];
/**
* @throws Exception\InvalidResponse
* @throws Exception\RateLimitReached
* @throws Exception\ExpiredAuthorization
*/
protected function _fetchAuthorizationResponses() {
if(!file_exists($this->_order->getKeyDirectoryPath() . 'private.pem')) {
Utilities\Logger::getInstance()->add(
Utilities\Logger::LEVEL_DEBUG,
get_class() . '::' . __FUNCTION__ . ' result suppressed (Order has finished already)'
);
return;
}
$orderResponse = Cache\OrderResponse::getInstance()->get($this->_order);
foreach($orderResponse->getAuthorizations() as $authorization) {
$request = new Request\Authorization\Get($this->_account, $authorization);
$this->_authorizationResponses[] = $request->getResponse();
}
}
protected function _hasValidAuthorizationResponses() : bool {
return count($this->_authorizationResponses) > 0;
}
public function shouldStartAuthorization() : bool {
foreach($this->_authorizationResponses as $response) {
$challenge = $response->getChallenge($this->_getChallengeType());
if($challenge->status == Response\Authorization\Struct\Challenge::STATUS_PENDING) {
Utilities\Logger::getInstance()->add(
Utilities\Logger::LEVEL_DEBUG,
get_class() . '::' . __FUNCTION__ . ' "Pending challenge found',
$challenge
);
return true;
}
}
return false;
}
abstract protected function _getChallengeType() : string;
/**
* @throws Exception\AuthorizationInvalid
* @throws Exception\InvalidResponse
* @throws Exception\RateLimitReached
* @throws Exception\ExpiredAuthorization
*/
public function progress() {
if(!$this->_hasValidAuthorizationResponses())
return;
$existsNotValidChallenges = false;
foreach($this->_authorizationResponses as $authorizationResponse) {
$challenge = $authorizationResponse->getChallenge($this->_getChallengeType());
if($this->_existsNotValidChallenges($challenge, $authorizationResponse)) {
$existsNotValidChallenges = true;
}
}
$this->_finished = !$existsNotValidChallenges;
}
/**
* @param Response\Authorization\Struct\Challenge $challenge
* @param Response\Authorization\Get $authorizationResponse
* @return bool
*
* @throws Exception\AuthorizationInvalid
*/
protected function _existsNotValidChallenges(Response\Authorization\Struct\Challenge $challenge,
Response\Authorization\Get $authorizationResponse
) : bool {
if($challenge->status == Response\Authorization\Struct\Challenge::STATUS_PENDING) {
Utilities\Logger::getInstance()->add(
Utilities\Logger::LEVEL_DEBUG,
get_class() . '::' . __FUNCTION__ . ' "Non valid challenge found',
$challenge
);
return true;
}
else if($challenge->status == Response\Authorization\Struct\Challenge::STATUS_PROGRESSING) {
// Should come back later
return true;
}
else if($challenge->status == Response\Authorization\Struct\Challenge::STATUS_VALID) {
}
else if($challenge->status == Response\Authorization\Struct\Challenge::STATUS_INVALID) {
throw new Exception\AuthorizationInvalid(
'Received status "' . Response\Authorization\Struct\Challenge::STATUS_INVALID . '" while challenge should be verified'
);
}
else {
throw new \RuntimeException('Challenge status "' . $challenge->status . '" is not implemented');
}
return false;
}
protected $_finished = false;
public function hasFinished() : bool {
Utilities\Logger::getInstance()->add(
Utilities\Logger::LEVEL_DEBUG,
get_called_class() . '::' . __FUNCTION__,
$this->_finished
);
return $this->_finished;
}
}

View File

@@ -0,0 +1,17 @@
<?php
namespace LE_ACME2\Authorizer;
defined('ABSPATH') or die();
use LE_ACME2\Order;
abstract class AbstractDNSWriter {
/**
* @param Order $order
* @param string $identifier
* @param string $digest
*
* @return bool return true, if the dns configuration is usable and the process should be progressed
*/
abstract public function write(Order $order, string $identifier, string $digest) : bool;
}

View File

@@ -0,0 +1,70 @@
<?php
namespace LE_ACME2\Authorizer;
defined('ABSPATH') or die();
use LE_ACME2\Request;
use LE_ACME2\Response;
use LE_ACME2\Exception;
use LE_ACME2\Order;
use LE_ACME2\Struct\ChallengeAuthorizationKey;
use LE_ACME2\Utilities;
class DNS extends AbstractAuthorizer {
protected function _getChallengeType(): string {
return Order::CHALLENGE_TYPE_DNS;
}
/** @var AbstractDNSWriter $_dnsWriter */
private static $_dnsWriter = null;
public static function setWriter(AbstractDNSWriter $dnsWriter) : void {
self::$_dnsWriter = $dnsWriter;
}
/**
* @param Response\Authorization\Struct\Challenge $challenge
* @param Response\Authorization\Get $authorizationResponse
* @return bool
*
* @throws Exception\AuthorizationInvalid
* @throws Exception\DNSAuthorizationInvalid
* @throws Exception\ExpiredAuthorization
* @throws Exception\InvalidResponse
* @throws Exception\RateLimitReached
*/
protected function _existsNotValidChallenges(Response\Authorization\Struct\Challenge $challenge,
Response\Authorization\Get $authorizationResponse
) : bool {
if($challenge->status == Response\Authorization\Struct\Challenge::STATUS_PENDING) {
if(self::$_dnsWriter === null) {
throw new \RuntimeException('DNS writer is not set');
}
if( self::$_dnsWriter->write(
$this->_order,
$authorizationResponse->getIdentifier()->value,
(new ChallengeAuthorizationKey($this->_account))->getEncoded($challenge->token)
)
) {
$request = new Request\Authorization\Start($this->_account, $this->_order, $challenge);
/* $response = */ $request->getResponse();
} else {
Utilities\Logger::getInstance()->add(Utilities\Logger::LEVEL_INFO, 'Pending challenge deferred');
}
}
if($challenge->status == Response\Authorization\Struct\Challenge::STATUS_INVALID) {
throw new Exception\DNSAuthorizationInvalid(
'Received status "' . Response\Authorization\Struct\Challenge::STATUS_INVALID . '" while challenge should be verified'
);
}
return parent::_existsNotValidChallenges($challenge, $authorizationResponse);
}
}

View File

@@ -0,0 +1,119 @@
<?php
namespace LE_ACME2\Authorizer;
defined('ABSPATH') or die();
use LE_ACME2\Request;
use LE_ACME2\Response;
use LE_ACME2\Struct\ChallengeAuthorizationKey;
use LE_ACME2\Utilities;
use LE_ACME2\Exception;
use LE_ACME2\Order;
class HTTP extends AbstractAuthorizer {
protected static $_directoryPath = null;
public static function setDirectoryPath(string $directoryPath) {
if(!file_exists($directoryPath)) {
throw new \RuntimeException('HTTP authorization directory path does not exist');
}
self::$_directoryPath = realpath($directoryPath) . DIRECTORY_SEPARATOR;
}
public static function getDirectoryPath() : ?string {
return self::$_directoryPath;
}
protected function _getChallengeType(): string {
return Order::CHALLENGE_TYPE_HTTP;
}
/**
* @param Response\Authorization\Struct\Challenge $challenge
* @param Response\Authorization\Get $authorizationResponse
* @return bool
*
* @throws Exception\AuthorizationInvalid
* @throws Exception\ExpiredAuthorization
* @throws Exception\InvalidResponse
* @throws Exception\RateLimitReached
*/
protected function _existsNotValidChallenges(Response\Authorization\Struct\Challenge $challenge,
Response\Authorization\Get $authorizationResponse
) : bool {
Utilities\Logger::getInstance()->add(
Utilities\Logger::LEVEL_DEBUG,
'Challenge "' . $challenge->token . '" has status:' . $challenge->status
);
if($challenge->status == Response\Authorization\Struct\Challenge::STATUS_PENDING) {
$this->_writeToFile($challenge);
if($this->_validateFile($authorizationResponse->getIdentifier()->value, $challenge)) {
$request = new Request\Authorization\Start($this->_account, $this->_order, $challenge);
/* $response = */ $request->getResponse();
} else {
Utilities\Logger::getInstance()->add(Utilities\Logger::LEVEL_INFO, 'Could not validate HTTP Authorization file');
}
}
if($challenge->status == Response\Authorization\Struct\Challenge::STATUS_INVALID) {
throw new Exception\HTTPAuthorizationInvalid(
'Received status "' . Response\Authorization\Struct\Challenge::STATUS_INVALID . '" while challenge should be verified'
);
}
return parent::_existsNotValidChallenges($challenge, $authorizationResponse);
}
private function _writeToFile(Response\Authorization\Struct\Challenge $challenge) : void {
file_put_contents(
self::$_directoryPath . $challenge->token,
(new ChallengeAuthorizationKey($this->_account))->get($challenge->token)
);
}
/**
* @param string $domain
* @param Response\Authorization\Struct\Challenge $challenge
* @return bool
*
* @throws Exception\HTTPAuthorizationInvalid
*/
private function _validateFile(string $domain, Response\Authorization\Struct\Challenge $challenge) : bool {
if ( get_option('rsssl_skip_challenge_directory_request') ) {
return true;
}
$challengeAuthorizationKey = new ChallengeAuthorizationKey($this->_account);
$requestURL = 'http://' . $domain . '/.well-known/acme-challenge/' . $challenge->token;
$handle = curl_init();
curl_setopt($handle, CURLOPT_URL, $requestURL);
curl_setopt($handle, CURLOPT_FOLLOWLOCATION, true);
curl_setopt($handle, CURLOPT_RETURNTRANSFER, true);
$response = curl_exec($handle);
$result = !empty($response) && $response == $challengeAuthorizationKey->get($challenge->token);
if(!$result) {
throw new Exception\HTTPAuthorizationInvalid(
'HTTP challenge for "' . $domain . '"": ' .
$domain . '/.well-known/acme-challenge/' . $challenge->token .
' tested, found invalid. CURL response: ' . var_export($response, true)
);
}
return true;
}
}

View File

@@ -0,0 +1,14 @@
<?php
namespace LE_ACME2\Cache;
defined('ABSPATH') or die();
use LE_ACME2\AbstractKeyValuable;
abstract class AbstractKeyValuableCache {
protected function __construct() {}
protected function _getObjectIdentifier(AbstractKeyValuable $object) : string {
return $object->getKeyDirectoryPath();
}
}

View File

@@ -0,0 +1,88 @@
<?php
namespace LE_ACME2\Cache;
defined('ABSPATH') or die();
use LE_ACME2\Account;
use LE_ACME2\Connector;
use LE_ACME2\Request;
use LE_ACME2\Response;
use LE_ACME2\Exception;
use LE_ACME2\Utilities;
use LE_ACME2\SingletonTrait;
class AccountResponse extends AbstractKeyValuableCache {
use SingletonTrait;
private const _FILE = 'CacheResponse';
private const _DEPRECATED_FILE = 'DirectoryNewAccountResponse';
private $_responses = [];
/**
* @param Account $account
* @return Response\Account\AbstractAccount
* @throws Exception\InvalidResponse
* @throws Exception\RateLimitReached
*/
public function get(Account $account): Response\Account\AbstractAccount {
$accountIdentifier = $this->_getObjectIdentifier($account);
if(array_key_exists($accountIdentifier, $this->_responses)) {
return $this->_responses[ $accountIdentifier ];
}
$this->_responses[ $accountIdentifier ] = null;
$cacheFile = $account->getKeyDirectoryPath() . self::_FILE;
$deprecatedCacheFile = $account->getKeyDirectoryPath() . self::_DEPRECATED_FILE;
if(file_exists($deprecatedCacheFile) && !file_exists($cacheFile)) {
rename($deprecatedCacheFile, $cacheFile);
}
if(file_exists($cacheFile) && filemtime($cacheFile) > strtotime('-7 days')) {
$rawResponse = Connector\RawResponse::getFromString(file_get_contents($cacheFile));
$response = new Response\Account\Create($rawResponse);
$this->_responses[ $accountIdentifier ] = $response;
Utilities\Logger::getInstance()->add(
Utilities\Logger::LEVEL_DEBUG,
get_class() . '::' . __FUNCTION__ . ' response from cache'
);
return $response;
}
$request = new Request\Account\Get($account);
$response = $request->getResponse();
$this->set($account, $response);
return $response;
}
public function set(Account $account, Response\Account\AbstractAccount $response = null) : void {
$accountIdentifier = $this->_getObjectIdentifier($account);
$filePath = $account->getKeyDirectoryPath() . self::_FILE;
if($response === null) {
unset($this->_responses[$accountIdentifier]);
if(file_exists($filePath)) {
unlink($filePath);
}
return;
}
$this->_responses[$accountIdentifier] = $response;
file_put_contents($filePath, $response->getRaw()->toString());
}
}

View File

@@ -0,0 +1,65 @@
<?php
namespace LE_ACME2\Cache;
defined('ABSPATH') or die();
use LE_ACME2\Connector;
use LE_ACME2\Account;
use LE_ACME2\SingletonTrait;
use LE_ACME2\Exception;
use LE_ACME2\Request;
use LE_ACME2\Response;
class DirectoryResponse {
use SingletonTrait;
private const _FILE = 'DirectoryResponse';
private function __construct() {}
private $_responses = [];
private $_index = 0;
/**
* @return Response\GetDirectory
* @throws Exception\InvalidResponse
* @throws Exception\RateLimitReached
*/
public function get() : Response\GetDirectory {
if(array_key_exists($this->_index, $this->_responses)) {
return $this->_responses[$this->_index];
}
$this->_responses[$this->_index] = null;
$cacheFile = Account::getCommonKeyDirectoryPath() . self::_FILE;
if(file_exists($cacheFile) && filemtime($cacheFile) > strtotime('-2 days')) {
$rawResponse = Connector\RawResponse::getFromString(file_get_contents($cacheFile));
try {
return $this->_responses[$this->_index] = new Response\GetDirectory($rawResponse);
} catch(Exception\AbstractException $e) {
unlink($cacheFile);
}
}
$request = new Request\GetDirectory();
$response = $request->getResponse();
$this->set($response);
return $response;
}
public function set(Response\GetDirectory $response) : void {
$cacheFile = Account::getCommonKeyDirectoryPath() . self::_FILE;
$this->_responses[$this->_index] = $response;
file_put_contents($cacheFile, $response->getRaw()->toString());
}
}

View File

@@ -0,0 +1,42 @@
<?php
namespace LE_ACME2\Cache;
defined('ABSPATH') or die();
use LE_ACME2\SingletonTrait;
use LE_ACME2\Exception;
use LE_ACME2\Request;
use LE_ACME2\Response;
class NewNonceResponse {
use SingletonTrait;
private function __construct() {}
private $_responses = [];
private $_index = 0;
/**
* @return Response\GetNewNonce
* @throws Exception\InvalidResponse
* @throws Exception\RateLimitReached
*/
public function get() : Response\GetNewNonce {
if(array_key_exists($this->_index, $this->_responses)) {
return $this->_responses[$this->_index];
}
$this->_responses[$this->_index] = null;
$request = new Request\GetNewNonce();
$response = $request->getResponse();
$this->set($response);
return $response;
}
public function set(Response\GetNewNonce $response) : void {
$this->_responses[$this->_index] = $response;
}
}

View File

@@ -0,0 +1,116 @@
<?php
namespace LE_ACME2\Cache;
defined('ABSPATH') or die();
use LE_ACME2\Connector;
use LE_ACME2\Order;
use LE_ACME2\Request;
use LE_ACME2\Response;
use LE_ACME2\Exception;
use LE_ACME2\Utilities;
use LE_ACME2\SingletonTrait;
class OrderResponse extends AbstractKeyValuableCache {
use SingletonTrait;
private const _FILE = 'CacheResponse';
private const _DEPRECATED_FILE = 'DirectoryNewOrderResponse';
private $_responses = [];
public function exists(Order $order) : bool {
$cacheFile = $order->getKeyDirectoryPath() . self::_FILE;
$deprecatedCacheFile = $order->getKeyDirectoryPath() . self::_DEPRECATED_FILE;
return file_exists($cacheFile) || file_exists($deprecatedCacheFile);
}
/**
* @param Order $order
* @return Response\Order\AbstractOrder
* @throws Exception\InvalidResponse
* @throws Exception\RateLimitReached
*/
public function get(Order $order): Response\Order\AbstractOrder {
$accountIdentifier = $this->_getObjectIdentifier($order->getAccount());
$orderIdentifier = $this->_getObjectIdentifier($order);
if(!isset($this->_responses[$accountIdentifier])) {
$this->_responses[$accountIdentifier] = [];
}
if(array_key_exists($orderIdentifier, $this->_responses[$accountIdentifier])) {
return $this->_responses[ $accountIdentifier ][ $orderIdentifier ];
}
$this->_responses[ $accountIdentifier ][ $orderIdentifier ] = null;
$cacheFile = $order->getKeyDirectoryPath() . self::_FILE;
$deprecatedCacheFile = $order->getKeyDirectoryPath() . self::_DEPRECATED_FILE;
if(file_exists($deprecatedCacheFile) && !file_exists($cacheFile)) {
rename($deprecatedCacheFile, $cacheFile);
}
if(file_exists($cacheFile)) {
$rawResponse = Connector\RawResponse::getFromString(file_get_contents($cacheFile));
$response = new Response\Order\Create($rawResponse);
if(
$response->getStatus() != Response\Order\AbstractOrder::STATUS_VALID
) {
Utilities\Logger::getInstance()->add(
Utilities\Logger::LEVEL_DEBUG,
get_class() . '::' . __FUNCTION__ . ' (cache did not satisfy, status "' . $response->getStatus() . '")'
);
$request = new Request\Order\Get($order, $response);
$response = $request->getResponse();
$this->set($order, $response);
return $response;
}
Utilities\Logger::getInstance()->add(
Utilities\Logger::LEVEL_DEBUG,
get_class() . '::' . __FUNCTION__ . ' (from cache, status "' . $response->getStatus() . '")'
);
$this->_responses[$accountIdentifier][$orderIdentifier] = $response;
return $response;
}
throw new \RuntimeException(
self::_FILE . ' could not be found for order: ' .
'- Path: ' . $order->getKeyDirectoryPath() . PHP_EOL .
'- Subjects: ' . var_export($order->getSubjects(), true) . PHP_EOL
);
}
public function set(Order $order, Response\Order\AbstractOrder $response = null) : void {
$accountIdentifier = $this->_getObjectIdentifier($order->getAccount());
$orderIdentifier = $this->_getObjectIdentifier($order);
$filePath = $order->getKeyDirectoryPath() . self::_FILE;
if($response === null) {
unset($this->_responses[$accountIdentifier][$orderIdentifier]);
if(file_exists($filePath)) {
unlink($filePath);
}
return;
}
$this->_responses[$accountIdentifier][$orderIdentifier] = $response;
file_put_contents($filePath, $response->getRaw()->toString());
}
}

View File

@@ -0,0 +1,111 @@
<?php
namespace LE_ACME2\Connector;
defined('ABSPATH') or die();
use LE_ACME2\Request;
use LE_ACME2\Response;
use LE_ACME2\SingletonTrait;
use LE_ACME2\Cache;
use LE_ACME2\Utilities;
use LE_ACME2\Exception;
class Connector {
use SingletonTrait;
const METHOD_GET = 'GET';
const METHOD_HEAD = 'HEAD';
const METHOD_POST = 'POST';
private function __construct() {}
protected $_baseURL = 'https://acme-v02.api.letsencrypt.org';
protected $_stagingBaseURL = 'https://acme-staging-v02.api.letsencrypt.org';
protected $_useStagingServer = true;
public function useStagingServer(bool $useStagingServer) {
$this->_useStagingServer = $useStagingServer;
}
public function isUsingStagingServer() : bool {
return $this->_useStagingServer;
}
public function getBaseURL() : string {
return $this->_useStagingServer ? $this->_stagingBaseURL : $this->_baseURL;
}
/**
* Makes a Curl request.
*
* @param string $method The HTTP method to use. Accepting GET, POST and HEAD requests.
* @param string $url The URL to make the request to.
* @param string $data The body to attach to a POST request. Expected as a JSON encoded string.
*
* @return RawResponse
* @throws Exception\InvalidResponse
* @throws Exception\RateLimitReached
*/
public function request(string $method, string $url, string $data = null) : RawResponse {
Utilities\Logger::getInstance()->add(Utilities\Logger::LEVEL_INFO, 'will request from ' . $url, $data);
$handle = curl_init();
$headers = array(
'Accept: application/json',
'Content-Type: ' . ($method == self::METHOD_POST ? 'application/jose+json' : 'application/json') // ACME draft-10, section 6.2
);
curl_setopt($handle, CURLOPT_URL, $url);
curl_setopt($handle, CURLOPT_HTTPHEADER, $headers);
curl_setopt($handle, CURLOPT_RETURNTRANSFER, true);
curl_setopt($handle, CURLOPT_HEADER, true);
switch ($method) {
case self::METHOD_GET:
break;
case self::METHOD_POST:
curl_setopt($handle, CURLOPT_POST, true);
curl_setopt($handle, CURLOPT_POSTFIELDS, $data);
break;
case self::METHOD_HEAD:
curl_setopt($handle, CURLOPT_CUSTOMREQUEST, 'HEAD');
curl_setopt($handle, CURLOPT_NOBODY, true);
break;
default:
throw new \RuntimeException('HTTP request ' . $method . ' not supported.');
break;
}
$response = curl_exec($handle);
if(curl_errno($handle)) {
throw new \RuntimeException('Curl: ' . curl_error($handle));
}
$header_size = curl_getinfo($handle, CURLINFO_HEADER_SIZE);
$rawResponse = new RawResponse();
$rawResponse->init($method, $url, $response, $header_size);
Utilities\Logger::getInstance()->add(Utilities\Logger::LEVEL_INFO, self::class . ': response received', $rawResponse);
try {
$getNewNonceResponse = new Response\GetNewNonce($rawResponse);
Cache\NewNonceResponse::getInstance()->set($getNewNonceResponse);
} catch(Exception\InvalidResponse $e) {
if($method == self::METHOD_POST) {
$request = new Request\GetNewNonce();
Cache\NewNonceResponse::getInstance()->set($request->getResponse());
}
}
return $rawResponse;
}
}

View File

@@ -0,0 +1,54 @@
<?php
namespace LE_ACME2\Connector;
defined('ABSPATH') or die();
class RawResponse {
/** @var string */
public $request;
/** @var array */
public $header;
/** @var array|string */
public $body;
public function init(string $method, string $url, string $response, int $headerSize) {
$header = substr($response, 0, $headerSize);
$body = substr($response, $headerSize);
$body_json = json_decode($body, true);
$this->request = $method . ' ' . $url;
$this->header = array_map(function($line) {
return trim($line);
}, explode("\n", $header));
$this->body = $body_json === null ? $body : $body_json;
}
public function toString() : string {
return serialize([
'request' => $this->request,
'header' => $this->header,
'body' => $this->body,
]);
}
public static function getFromString(string $string) : self {
$array = unserialize($string);
$rawResponse = new self();
$rawResponse->request = $array['request'];
$rawResponse->header = $array['header'];
$rawResponse->body = $array['body'];
return $rawResponse;
}
}

View File

@@ -0,0 +1,19 @@
<?php
namespace LE_ACME2\Exception;
defined('ABSPATH') or die();
use LE_ACME2\Utilities;
abstract class AbstractException extends \Exception {
public function __construct(string $message) {
Utilities\Logger::getInstance()->add(
Utilities\Logger::LEVEL_DEBUG,
'Exception "' . get_called_class() . '" thrown '
);
parent::__construct($message);
}
}

View File

@@ -0,0 +1,6 @@
<?php
namespace LE_ACME2\Exception;
defined('ABSPATH') or die();
class AuthorizationInvalid extends AbstractException {}

View File

@@ -0,0 +1,6 @@
<?php
namespace LE_ACME2\Exception;
defined('ABSPATH') or die();
class DNSAuthorizationInvalid extends AuthorizationInvalid {}

View File

@@ -0,0 +1,11 @@
<?php
namespace LE_ACME2\Exception;
defined('ABSPATH') or die();
class ExpiredAuthorization extends AbstractException {
public function __construct() {
parent::__construct("Expired authorization received");
}
}

View File

@@ -0,0 +1,6 @@
<?php
namespace LE_ACME2\Exception;
defined('ABSPATH') or die();
class HTTPAuthorizationInvalid extends AuthorizationInvalid {}

View File

@@ -0,0 +1,40 @@
<?php
namespace LE_ACME2\Exception;
defined('ABSPATH') or die();
use LE_ACME2\Connector\RawResponse;
class InvalidResponse extends AbstractException {
private $_rawResponse;
private $_responseStatus;
public function __construct(RawResponse $rawResponse, string $responseStatus = null) {
$this->_rawResponse = $rawResponse;
$this->_responseStatus = $responseStatus;
if($responseStatus === '') {
$responseStatus = 'Unknown response status';
}
if(isset($this->_rawResponse->body['type'])) {
$responseStatus = $this->_rawResponse->body['type'];
}
if(isset($this->_rawResponse->body['detail'])) {
$responseStatus .= ' - ' . $this->_rawResponse->body['detail'];
}
parent::__construct('Invalid response received: ' . $responseStatus);
}
public function getRawResponse() : RawResponse {
return $this->_rawResponse;
}
public function getResponseStatus() : ?string {
return $this->_responseStatus;
}
}

View File

@@ -0,0 +1,19 @@
<?php
namespace LE_ACME2\Exception;
defined('ABSPATH') or die();
class OpenSSLException extends AbstractException {
public function __construct(string $function) {
$errors = [];
while(($error = openssl_error_string()) !== false) {
$errors[] = $error;
}
parent::__construct(
$function . ' failed - error messages: ' . var_export($errors, true)
);
}
}

View File

@@ -0,0 +1,14 @@
<?php
namespace LE_ACME2\Exception;
defined('ABSPATH') or die();
class RateLimitReached extends AbstractException {
public function __construct(string $request, string $detail) {
parent::__construct(
"Invalid response received for request (" . $request . "): " .
"rate limit reached - " . $detail
);
}
}

View File

@@ -0,0 +1,6 @@
<?php
namespace LE_ACME2\Exception;
defined('ABSPATH') or die();
class StatusInvalid extends AbstractException {}

View File

@@ -0,0 +1,438 @@
<?php
namespace LE_ACME2;
defined('ABSPATH') or die();
use LE_ACME2\Request;
use LE_ACME2\Response;
use LE_ACME2\Cache;
use LE_ACME2\Authorizer;
use LE_ACME2\Exception;
use LE_ACME2\Utilities;
class Order extends AbstractKeyValuable {
const CHALLENGE_TYPE_HTTP = 'http-01';
const CHALLENGE_TYPE_DNS = 'dns-01';
/**
* @deprecated
* @param $directoryPath
*/
public static function setHTTPAuthorizationDirectoryPath(string $directoryPath) {
Authorizer\HTTP::setDirectoryPath($directoryPath);
}
CONST IDENTRUST_ISSUER_CN = 'DST Root CA X3';
/** @var string|null $_preferredChain */
private static $_preferredChain = null;
public static function setPreferredChain(string $issuerCN = null) {
self::$_preferredChain = $issuerCN;
}
protected $_account;
protected $_subjects;
public function __construct(Account $account, array $subjects) {
array_map(function($subject) {
if(preg_match_all('~(\*\.)~', $subject) > 1)
throw new \RuntimeException('Cannot create orders with multiple wildcards in one domain.');
}, $subjects);
$this->_account = $account;
$this->_subjects = $subjects;
$this->_identifier = $this->_getAccountIdentifier($account) . DIRECTORY_SEPARATOR .
'order_' . md5(implode('|', $subjects));
Utilities\Logger::getInstance()->add(
Utilities\Logger::LEVEL_INFO,
get_class() . '::' . __FUNCTION__ . ' "' . implode(':', $this->getSubjects()) . '"'
);
Utilities\Logger::getInstance()->add(
Utilities\Logger::LEVEL_DEBUG,
get_class() . '::' . __FUNCTION__ . ' path: ' . $this->getKeyDirectoryPath()
);
}
public function getAccount() : Account {
return $this->_account;
}
public function getSubjects() : array {
return $this->_subjects;
}
/**
* @param Account $account
* @param array $subjects
* @param string $keyType
* @return Order
* @throws Exception\AbstractException
*/
public static function create(Account $account, array $subjects, string $keyType = self::KEY_TYPE_RSA) : Order {
$order = new self($account, $subjects);
return $order->_create($keyType, false);
}
/**
* @param $keyType
* @param bool $ignoreIfKeysExist
* @return Order
* @throws Exception\AbstractException
*/
protected function _create(string $keyType, bool $ignoreIfKeysExist = false) : Order {
$this->_initKeyDirectory($keyType, $ignoreIfKeysExist);
$request = new Request\Order\Create($this);
try {
$response = $request->getResponse();
Cache\OrderResponse::getInstance()->set($this, $response);
return $this;
} catch(Exception\AbstractException $e) {
$this->_clearKeyDirectory();
throw $e;
}
}
public static function exists(Account $account, array $subjects) : bool {
$order = new self($account, $subjects);
return Cache\OrderResponse::getInstance()->exists($order);
}
public static function get(Account $account, array $subjects) : Order {
$order = new self($account, $subjects);
if(!self::exists($account, $subjects))
throw new \RuntimeException('Order does not exist');
return $order;
}
/** @var Authorizer\AbstractAuthorizer|Authorizer\HTTP|null $_authorizer */
protected $_authorizer = null;
/**
* @param $type
* @return Authorizer\AbstractAuthorizer|Authorizer\HTTP|null
* @throws Exception\InvalidResponse
* @throws Exception\RateLimitReached
* @throws Exception\ExpiredAuthorization
*/
protected function _getAuthorizer(string $type) : Authorizer\AbstractAuthorizer {
if($this->_authorizer === null) {
if($type == self::CHALLENGE_TYPE_HTTP) {
$this->_authorizer = new Authorizer\HTTP($this->_account, $this);
} else if($type == self::CHALLENGE_TYPE_DNS) {
$this->_authorizer = new Authorizer\DNS($this->_account, $this);
} else {
throw new \RuntimeException('Challenge type not implemented');
}
}
return $this->_authorizer;
}
/**
* The Authorization has expired, so we clean the complete order to restart again on the next call
*/
protected function _clearAfterExpiredAuthorization() {
Utilities\Logger::getInstance()->add(
Utilities\Logger::LEVEL_INFO,
get_class() . '::' . __FUNCTION__ . ' "Will clear after expired authorization'
);
$this->clear();
}
public function clear() {
Cache\OrderResponse::getInstance()->set($this, null);
$this->_clearKeyDirectory();
}
/**
* @return bool
* @param $type
* @throws Exception\InvalidResponse
* @throws Exception\RateLimitReached
*/
public function shouldStartAuthorization(string $type) : bool {
try {
return $this->_getAuthorizer($type)->shouldStartAuthorization();
} catch(Exception\ExpiredAuthorization $e) {
$this->_clearAfterExpiredAuthorization();
return false;
}
}
/**
* @param $type
* @return bool
* @throws Exception\InvalidResponse
* @throws Exception\RateLimitReached
* @throws Exception\AuthorizationInvalid
*/
public function authorize(string $type) : bool {
try {
$authorizer = $this->_getAuthorizer($type);
$authorizer->progress();
return $authorizer->hasFinished();
} catch(Exception\ExpiredAuthorization $e) {
$this->_clearAfterExpiredAuthorization();
return false;
}
}
/**
* @throws Exception\InvalidResponse
* @throws Exception\RateLimitReached
*/
public function finalize() {
if(!is_object($this->_authorizer) || !$this->_authorizer->hasFinished()) {
throw new \RuntimeException('Not all challenges are valid. Please check result of authorize() first!');
}
Utilities\Logger::getInstance()->add(
Utilities\Logger::LEVEL_INFO,
get_class() . '::' . __FUNCTION__ . ' "Will finalize'
);
$orderResponse = Cache\OrderResponse::getInstance()->get($this);
if(
$orderResponse->getStatus() == Response\Order\AbstractOrder::STATUS_PENDING /* DEPRECATED AFTER JULI 5TH 2018 */ ||
$orderResponse->getStatus() == Response\Order\AbstractOrder::STATUS_READY // ACME draft-12 Section 7.1.6
) {
$request = new Request\Order\Finalize($this, $orderResponse);
$orderResponse = $request->getResponse();
Cache\OrderResponse::getInstance()->set($this, $orderResponse);
}
if($orderResponse->getStatus() == Response\Order\AbstractOrder::STATUS_VALID) {
$request = new Request\Order\GetCertificate($this, $orderResponse);
$response = $request->getResponse();
$certificate = $response->getCertificate();
$intermediate = $response->getIntermediate();
//$certificateInfo = openssl_x509_parse($certificate);
//$certificateValidToTimeTimestamp = $certificateInfo['validTo_time_t'];
$intermediateInfo = openssl_x509_parse($intermediate);
if(self::$_preferredChain !== null) {
Utilities\Logger::getInstance()->add(
Utilities\Logger::LEVEL_INFO,
'Preferred chain is set: ' . self::$_preferredChain
);
}
$found = false;
if(self::$_preferredChain !== null && $intermediateInfo['issuer']['CN'] != self::$_preferredChain) {
Utilities\Logger::getInstance()->add(
Utilities\Logger::LEVEL_INFO,
'Default certificate does not satisfy preferred chain, trying to fetch alternative'
);
foreach($response->getAlternativeLinks() as $link) {
$request = new Request\Order\GetCertificate($this, $orderResponse, $link);
$response = $request->getResponse();
$alternativeCertificate = $response->getCertificate();
$alternativeIntermediate = $response->getIntermediate();
$intermediateInfo = openssl_x509_parse($intermediate);
if($intermediateInfo['issuer']['CN'] != self::$_preferredChain) {
continue;
}
$found = true;
$certificate = $alternativeCertificate;
$intermediate = $alternativeIntermediate;
break;
}
if(!$found) {
Utilities\Logger::getInstance()->add(
Utilities\Logger::LEVEL_INFO,
'Preferred chain could not be satisfied, returning default chain'
);
}
}
$this->_saveCertificate($certificate, $intermediate);
}
}
private function _saveCertificate(string $certificate, string $intermediate) : void {
$certificateInfo = openssl_x509_parse($certificate);
$certificateValidToTimeTimestamp = $certificateInfo['validTo_time_t'];
$path = $this->getKeyDirectoryPath() . self::BUNDLE_DIRECTORY_PREFIX . $certificateValidToTimeTimestamp . DIRECTORY_SEPARATOR;
mkdir($path);
rename($this->getKeyDirectoryPath() . 'private.pem', $path . 'private.pem');
file_put_contents($path . 'certificate.crt', $certificate);
file_put_contents($path . 'intermediate.pem', $intermediate);
Utilities\Logger::getInstance()->add(Utilities\Logger::LEVEL_INFO, 'Certificate received');
}
const BUNDLE_DIRECTORY_PREFIX = 'bundle_';
protected function _getLatestCertificateDirectory() : ?string {
$files = scandir($this->getKeyDirectoryPath(), SORT_NUMERIC | SORT_DESC);
foreach($files as $file) {
if(
substr($file, 0, strlen(self::BUNDLE_DIRECTORY_PREFIX)) == self::BUNDLE_DIRECTORY_PREFIX &&
is_dir($this->getKeyDirectoryPath() . $file)
) {
return $file;
}
}
return null;
}
public function isCertificateBundleAvailable() : bool {
return $this->_getLatestCertificateDirectory() !== NULL;
}
public function getCertificateBundle() : Struct\CertificateBundle {
if(!$this->isCertificateBundleAvailable()) {
throw new \RuntimeException('There is no certificate available');
}
$certificatePath = $this->getKeyDirectoryPath() . $this->_getLatestCertificateDirectory();
return new Struct\CertificateBundle(
$certificatePath . DIRECTORY_SEPARATOR,
'private.pem',
'certificate.crt',
'intermediate.pem',
self::_getExpireTimeFromCertificateDirectoryPath($certificatePath)
);
}
/**
* @param string $keyType
* @param int|null $renewBefore Unix timestamp
* @throws Exception\AbstractException
*/
public function enableAutoRenewal($keyType = self::KEY_TYPE_RSA, int $renewBefore = null) {
if($keyType === null) {
$keyType = self::KEY_TYPE_RSA;
}
if(!$this->isCertificateBundleAvailable()) {
throw new \RuntimeException('There is no certificate available');
}
$orderResponse = Cache\OrderResponse::getInstance()->get($this);
if(
$orderResponse === null ||
$orderResponse->getStatus() != Response\Order\AbstractOrder::STATUS_VALID
) {
return;
}
Utilities\Logger::getInstance()->add(Utilities\Logger::LEVEL_DEBUG,'Auto renewal triggered');
$directory = $this->_getLatestCertificateDirectory();
$expireTime = self::_getExpireTimeFromCertificateDirectoryPath($directory);
if($renewBefore === null) {
$renewBefore = strtotime('-30 days', $expireTime);
}
if($renewBefore < time()) {
Utilities\Logger::getInstance()->add(Utilities\Logger::LEVEL_INFO,'Auto renewal: Will recreate order');
$this->_create($keyType, true);
}
}
/**
* @param int $reason The reason to revoke the LetsEncrypt Order instance certificate.
* Possible reasons can be found in section 5.3.1 of RFC5280.
* @return bool
* @throws Exception\RateLimitReached
*/
public function revokeCertificate(int $reason = 0) : bool {
if(!$this->isCertificateBundleAvailable()) {
throw new \RuntimeException('There is no certificate available to revoke');
}
$bundle = $this->getCertificateBundle();
$request = new Request\Order\RevokeCertificate($bundle, $reason);
try {
/* $response = */ $request->getResponse();
rename(
$this->getKeyDirectoryPath(),
$this->_getKeyDirectoryPath('-revoked-' . microtime(true))
);
return true;
} catch(Exception\InvalidResponse $e) {
return false;
}
}
protected static function _getExpireTimeFromCertificateDirectoryPath(string $path) {
$stringPosition = strrpos($path, self::BUNDLE_DIRECTORY_PREFIX);
if($stringPosition === false) {
throw new \RuntimeException('ExpireTime not found in' . $path);
}
$expireTime = substr($path, $stringPosition + strlen(self::BUNDLE_DIRECTORY_PREFIX));
if(
!is_numeric($expireTime) ||
$expireTime < strtotime('-10 years') ||
$expireTime > strtotime('+10 years')
) {
throw new \RuntimeException('Unexpected expireTime: ' . $expireTime);
}
return $expireTime;
}
}

View File

@@ -0,0 +1,26 @@
<?php
namespace LE_ACME2\Request;
defined('ABSPATH') or die();
use LE_ACME2\Response\AbstractResponse;
use LE_ACME2\Exception;
abstract class AbstractRequest {
/**
* @return AbstractResponse
* @throws Exception\InvalidResponse
* @throws Exception\RateLimitReached
*/
abstract public function getResponse() : AbstractResponse;
protected function _buildContactPayload(string $email) : array {
$result = [
'mailto:' . $email
];
return $result;
}
}

View File

@@ -0,0 +1,53 @@
<?php
namespace LE_ACME2\Request\Account;
defined('ABSPATH') or die();
use LE_ACME2\Request\AbstractRequest;
use LE_ACME2\Connector;
use LE_ACME2\Cache;
use LE_ACME2\Utilities;
use LE_ACME2\Exception;
use LE_ACME2\Account;
abstract class AbstractLocation extends AbstractRequest {
protected $_account;
public function __construct(Account $account) {
$this->_account = $account;
}
/**
* @return Connector\RawResponse
* @throws Exception\InvalidResponse
* @throws Exception\RateLimitReached
*/
protected function _getRawResponse() : Connector\RawResponse {
$payload = $this->_getPayload();
if(count($payload) == 0) {
$payload['rand-' . rand(100000, 1000000)] = 1;
}
$kid = Utilities\RequestSigner::KID(
$payload,
Cache\AccountResponse::getInstance()->get($this->_account)->getLocation(),
Cache\AccountResponse::getInstance()->get($this->_account)->getLocation(),
Cache\NewNonceResponse::getInstance()->get()->getNonce(),
$this->_account->getKeyDirectoryPath()
);
$result = Connector\Connector::getInstance()->request(
Connector\Connector::METHOD_POST,
Cache\AccountResponse::getInstance()->get($this->_account)->getLocation(),
$kid
);
return $result;
}
abstract protected function _getPayload() : array;
}

View File

@@ -0,0 +1,84 @@
<?php
namespace LE_ACME2\Request\Account;
defined('ABSPATH') or die();
use LE_ACME2\Request\AbstractRequest;
use LE_ACME2\Response;
use LE_ACME2\Connector;
use LE_ACME2\Cache;
use LE_ACME2\Utilities;
use LE_ACME2\Exception;
use LE_ACME2\Account;
class ChangeKeys extends AbstractRequest {
protected $_account;
public function __construct(Account $account) {
$this->_account = $account;
}
/**
* @return Response\AbstractResponse|Response\Account\ChangeKeys
* @throws Exception\InvalidResponse
* @throws Exception\RateLimitReached
*/
public function getResponse() : Response\AbstractResponse {
$currentPrivateKey = openssl_pkey_get_private(
file_get_contents($this->_account->getKeyDirectoryPath() . 'private.pem')
);
$currentPrivateKeyDetails = openssl_pkey_get_details($currentPrivateKey);
/**
* draft-13 Section 7.3.6
* "newKey" is deprecated after August 23rd 2018
*/
$newPrivateKey = openssl_pkey_get_private(
file_get_contents($this->_account->getKeyDirectoryPath() . 'private-replacement.pem')
);
$newPrivateKeyDetails = openssl_pkey_get_details($newPrivateKey);
$innerPayload = [
'account' => Cache\AccountResponse::getInstance()->get($this->_account)->getLocation(),
'oldKey' => [
"kty" => "RSA",
"n" => Utilities\Base64::UrlSafeEncode($currentPrivateKeyDetails["rsa"]["n"]),
"e" => Utilities\Base64::UrlSafeEncode($currentPrivateKeyDetails["rsa"]["e"])
],
'newKey' => [
"kty" => "RSA",
"n" => Utilities\Base64::UrlSafeEncode($newPrivateKeyDetails["rsa"]["n"]),
"e" => Utilities\Base64::UrlSafeEncode($newPrivateKeyDetails["rsa"]["e"])
]
];
$outerPayload = Utilities\RequestSigner::JWK(
$innerPayload,
Cache\DirectoryResponse::getInstance()->get()->getKeyChange(),
Cache\NewNonceResponse::getInstance()->get()->getNonce(),
$this->_account->getKeyDirectoryPath(),
'private-replacement.pem'
);
$data = Utilities\RequestSigner::KID(
$outerPayload,
Cache\AccountResponse::getInstance()->get($this->_account)->getLocation(),
Cache\DirectoryResponse::getInstance()->get()->getKeyChange(),
Cache\NewNonceResponse::getInstance()->get()->getNonce(),
$this->_account->getKeyDirectoryPath(),
'private.pem'
);
$result = Connector\Connector::getInstance()->request(
Connector\Connector::METHOD_POST,
Cache\DirectoryResponse::getInstance()->get()->getKeyChange(),
$data
);
return new Response\Account\ChangeKeys($result);
}
}

View File

@@ -0,0 +1,51 @@
<?php
namespace LE_ACME2\Request\Account;
defined('ABSPATH') or die();
use LE_ACME2\Request\AbstractRequest;
use LE_ACME2\Response;
use LE_ACME2\Connector;
use LE_ACME2\Cache;
use LE_ACME2\Utilities;
use LE_ACME2\Exception;
use LE_ACME2\Account;
class Create extends AbstractRequest {
protected $_account;
public function __construct(Account $account) {
$this->_account = $account;
}
/**
* @return Response\AbstractResponse|Response\Account\Create
* @throws Exception\InvalidResponse
* @throws Exception\RateLimitReached
*/
public function getResponse() : Response\AbstractResponse {
$payload = [
'contact' => $this->_buildContactPayload($this->_account->getEmail()),
'termsOfServiceAgreed' => true,
];
$jwk = Utilities\RequestSigner::JWKString(
$payload,
Cache\DirectoryResponse::getInstance()->get()->getNewAccount(),
Cache\NewNonceResponse::getInstance()->get()->getNonce(),
$this->_account->getKeyDirectoryPath()
);
$result = Connector\Connector::getInstance()->request(
Connector\Connector::METHOD_POST,
Cache\DirectoryResponse::getInstance()->get()->getNewAccount(),
$jwk
);
return new Response\Account\Create($result);
}
}

View File

@@ -0,0 +1,28 @@
<?php
namespace LE_ACME2\Request\Account;
defined('ABSPATH') or die();
use LE_ACME2\Response;
use LE_ACME2\Exception;
class Deactivate extends AbstractLocation {
protected function _getPayload() : array {
return [
'status' => 'deactivated',
];
}
/**
* @return Response\AbstractResponse|Response\Account\Deactivate
* @throws Exception\InvalidResponse
* @throws Exception\RateLimitReached
*/
public function getResponse() : Response\AbstractResponse {
return new Response\Account\Deactivate($this->_getRawResponse());
}
}

View File

@@ -0,0 +1,50 @@
<?php
namespace LE_ACME2\Request\Account;
defined('ABSPATH') or die();
use LE_ACME2\Request\AbstractRequest;
use LE_ACME2\Response;
use LE_ACME2\Connector;
use LE_ACME2\Cache;
use LE_ACME2\Exception;
use LE_ACME2\Utilities;
use LE_ACME2\Account;
class Get extends AbstractRequest {
protected $_account;
public function __construct(Account $account) {
$this->_account = $account;
}
/**
* @return Response\AbstractResponse|Response\Account\Get
* @throws Exception\InvalidResponse
* @throws Exception\RateLimitReached
*/
public function getResponse() : Response\AbstractResponse {
$payload = [
'onlyReturnExisting' => true,
];
$jwk = Utilities\RequestSigner::JWKString(
$payload,
Cache\DirectoryResponse::getInstance()->get()->getNewAccount(),
Cache\NewNonceResponse::getInstance()->get()->getNonce(),
$this->_account->getKeyDirectoryPath()
);
$result = Connector\Connector::getInstance()->request(
Connector\Connector::METHOD_POST,
Cache\DirectoryResponse::getInstance()->get()->getNewAccount(),
$jwk
);
return new Response\Account\Get($result);
}
}

View File

@@ -0,0 +1,26 @@
<?php
namespace LE_ACME2\Request\Account;
defined('ABSPATH') or die();
use LE_ACME2\Response;
use LE_ACME2\Exception;
class GetData extends AbstractLocation {
protected function _getPayload() : array {
return [];
}
/**
* @return Response\AbstractResponse|Response\Account\GetData
* @throws Exception\InvalidResponse
* @throws Exception\RateLimitReached
*/
public function getResponse() : Response\AbstractResponse {
return new Response\Account\GetData($this->_getRawResponse());
}
}

View File

@@ -0,0 +1,38 @@
<?php
namespace LE_ACME2\Request\Account;
defined('ABSPATH') or die();
use LE_ACME2\Response;
use LE_ACME2\Exception;
use LE_ACME2\Account;
class Update extends AbstractLocation {
protected $_newEmail;
public function __construct(Account $account, $newEmail) {
parent::__construct($account);
$this->_newEmail = $newEmail;
}
protected function _getPayload() : array {
return [
'contact' => $this->_buildContactPayload($this->_newEmail),
];
}
/**
* @return Response\AbstractResponse|Response\Account\Update
* @throws Exception\InvalidResponse
* @throws Exception\RateLimitReached
*/
public function getResponse() : Response\AbstractResponse {
return new Response\Account\Update($this->_getRawResponse());
}
}

View File

@@ -0,0 +1,51 @@
<?php
namespace LE_ACME2\Request\Authorization;
defined('ABSPATH') or die();
use LE_ACME2\Request\AbstractRequest;
use LE_ACME2\Connector;
use LE_ACME2\Cache;
use LE_ACME2\Exception;
use LE_ACME2\Response;
use LE_ACME2\Utilities;
use LE_ACME2\Account;
class Get extends AbstractRequest {
protected $_account;
protected $_authorizationURL;
public function __construct(Account $account, string $authorizationURL) {
$this->_account = $account;
$this->_authorizationURL = $authorizationURL;
}
/**
* @return Response\AbstractResponse|Response\Authorization\Get
* @throws Exception\InvalidResponse
* @throws Exception\RateLimitReached
* @throws Exception\ExpiredAuthorization
*/
public function getResponse() : Response\AbstractResponse {
$kid = Utilities\RequestSigner::KID(
null,
Cache\AccountResponse::getInstance()->get($this->_account)->getLocation(),
$this->_authorizationURL,
Cache\NewNonceResponse::getInstance()->get()->getNonce(),
$this->_account->getKeyDirectoryPath()
);
$result = Connector\Connector::getInstance()->request(
Connector\Connector::METHOD_POST,
$this->_authorizationURL,
$kid
);
return new Response\Authorization\Get($result);
}
}

View File

@@ -0,0 +1,59 @@
<?php
namespace LE_ACME2\Request\Authorization;
defined('ABSPATH') or die();
use LE_ACME2\Request\AbstractRequest;
use LE_ACME2\Connector;
use LE_ACME2\Cache;
use LE_ACME2\Exception;
use LE_ACME2\Response;
use LE_ACME2\Struct\ChallengeAuthorizationKey;
use LE_ACME2\Utilities;
use LE_ACME2\Account;
use LE_ACME2\Order;
class Start extends AbstractRequest {
protected $_account;
protected $_order;
protected $_challenge;
public function __construct(Account $account, Order $order, Response\Authorization\Struct\Challenge $challenge) {
$this->_account = $account;
$this->_order = $order;
$this->_challenge = $challenge;
}
/**
* @return Response\AbstractResponse|Response\Authorization\Start
* @throws Exception\InvalidResponse
* @throws Exception\RateLimitReached
* @throws Exception\ExpiredAuthorization
*/
public function getResponse() : Response\AbstractResponse {
$payload = [
'keyAuthorization' => (new ChallengeAuthorizationKey($this->_account))->get($this->_challenge->token)
];
$kid = Utilities\RequestSigner::KID(
$payload,
Cache\AccountResponse::getInstance()->get($this->_account)->getLocation(),
$this->_challenge->url,
Cache\NewNonceResponse::getInstance()->get()->getNonce(),
$this->_account->getKeyDirectoryPath()
);
$result = Connector\Connector::getInstance()->request(
Connector\Connector::METHOD_POST,
$this->_challenge->url,
$kid
);
return new Response\Authorization\Start($result);
}
}

View File

@@ -0,0 +1,28 @@
<?php
namespace LE_ACME2\Request;
defined('ABSPATH') or die();
use LE_ACME2\Response;
use LE_ACME2\Connector\Connector;
use LE_ACME2\Exception;
class GetDirectory extends AbstractRequest {
/**
* @return Response\AbstractResponse|Response\GetDirectory
* @throws Exception\InvalidResponse
* @throws Exception\RateLimitReached
*/
public function getResponse() : Response\AbstractResponse {
$connector = Connector::getInstance();
$result = $connector->request(
Connector::METHOD_GET,
$connector->getBaseURL() . '/directory'
);
return new Response\GetDirectory($result);
}
}

View File

@@ -0,0 +1,28 @@
<?php
namespace LE_ACME2\Request;
defined('ABSPATH') or die();
use LE_ACME2\Response;
use LE_ACME2\Connector;
use LE_ACME2\Cache;
use LE_ACME2\Exception;
class GetNewNonce extends AbstractRequest {
/**
* @return Response\AbstractResponse|Response\GetNewNonce
* @throws Exception\InvalidResponse
* @throws Exception\RateLimitReached
*/
public function getResponse() : Response\AbstractResponse {
$result = Connector\Connector::getInstance()->request(
Connector\Connector::METHOD_HEAD,
Cache\DirectoryResponse::getInstance()->get()->getNewNonce()
);
return new Response\GetNewNonce($result);
}
}

View File

@@ -0,0 +1,62 @@
<?php
namespace LE_ACME2\Request\Order;
defined('ABSPATH') or die();
use LE_ACME2\Request\AbstractRequest;
use LE_ACME2\Response;
use LE_ACME2\Connector;
use LE_ACME2\Cache;
use LE_ACME2\Exception;
use LE_ACME2\Utilities;
use LE_ACME2\Order;
class Create extends AbstractRequest {
protected $_order;
public function __construct(Order $order) {
$this->_order = $order;
}
/**
* @return Response\AbstractResponse|Response\Order\Create
* @throws Exception\InvalidResponse
* @throws Exception\RateLimitReached
*/
public function getResponse() : Response\AbstractResponse {
$identifiers = [];
foreach($this->_order->getSubjects() as $subject) {
$identifiers[] = [
'type' => 'dns',
'value' => $subject
];
}
$payload = [
'identifiers' => $identifiers,
'notBefore' => '',
'notAfter' => '',
];
$kid = Utilities\RequestSigner::KID(
$payload,
Cache\AccountResponse::getInstance()->get($this->_order->getAccount())->getLocation(),
Cache\DirectoryResponse::getInstance()->get()->getNewOrder(),
Cache\NewNonceResponse::getInstance()->get()->getNonce(),
$this->_order->getAccount()->getKeyDirectoryPath()
);
$result = Connector\Connector::getInstance()->request(
Connector\Connector::METHOD_POST,
Cache\DirectoryResponse::getInstance()->get()->getNewOrder(),
$kid
);
return new Response\Order\Create($result);
}
}

View File

@@ -0,0 +1,61 @@
<?php
namespace LE_ACME2\Request\Order;
defined('ABSPATH') or die();
use LE_ACME2\Request\AbstractRequest;
use LE_ACME2\Response;
use LE_ACME2\Connector;
use LE_ACME2\Cache;
use LE_ACME2\Exception;
use LE_ACME2\Utilities;
use LE_ACME2\Order;
class Finalize extends AbstractRequest {
protected $_order;
protected $_orderResponse;
public function __construct(Order $order, Response\Order\AbstractOrder $orderResponse) {
$this->_order = $order;
$this->_orderResponse = $orderResponse;
}
/**
* @return Response\AbstractResponse|Response\Order\Finalize
* @throws Exception\InvalidResponse
* @throws Exception\RateLimitReached
*/
public function getResponse() : Response\AbstractResponse {
$csr = Utilities\Certificate::generateCSR($this->_order);
if(preg_match('~-----BEGIN\sCERTIFICATE\sREQUEST-----(.*)-----END\sCERTIFICATE\sREQUEST-----~s', $csr, $matches))
$csr = $matches[1];
$csr = trim(Utilities\Base64::UrlSafeEncode(base64_decode($csr)));
$payload = [
'csr' => $csr
];
$kid = Utilities\RequestSigner::KID(
$payload,
Cache\AccountResponse::getInstance()->get($this->_order->getAccount())->getLocation(),
$this->_orderResponse->getFinalize(),
Cache\NewNonceResponse::getInstance()->get()->getNonce(),
$this->_order->getAccount()->getKeyDirectoryPath()
);
$result = Connector\Connector::getInstance()->request(
Connector\Connector::METHOD_POST,
$this->_orderResponse->getFinalize(),
$kid
);
return new Response\Order\Finalize($result);
}
}

View File

@@ -0,0 +1,50 @@
<?php
namespace LE_ACME2\Request\Order;
defined('ABSPATH') or die();
use LE_ACME2\Request\AbstractRequest;
use LE_ACME2\Response;
use LE_ACME2\Connector;
use LE_ACME2\Cache;
use LE_ACME2\Exception;
use LE_ACME2\Utilities;
use LE_ACME2\Order;
class Get extends AbstractRequest {
protected $_order;
protected $_orderResponse;
public function __construct(Order $order, Response\Order\AbstractOrder $orderResponse) {
$this->_order = $order;
$this->_orderResponse = $orderResponse;
}
/**
* @return Response\AbstractResponse|Response\Order\Get
* @throws Exception\InvalidResponse
* @throws Exception\RateLimitReached
*/
public function getResponse() : Response\AbstractResponse {
$kid = Utilities\RequestSigner::KID(
null,
Cache\AccountResponse::getInstance()->get($this->_order->getAccount())->getLocation(),
$this->_orderResponse->getLocation(),
Cache\NewNonceResponse::getInstance()->get()->getNonce(),
$this->_order->getAccount()->getKeyDirectoryPath()
);
$result = Connector\Connector::getInstance()->request(
Connector\Connector::METHOD_POST,
$this->_orderResponse->getLocation(),
$kid
);
return new Response\Order\Get($result, $this->_orderResponse->getLocation());
}
}

View File

@@ -0,0 +1,60 @@
<?php
namespace LE_ACME2\Request\Order;
defined('ABSPATH') or die();
use LE_ACME2\Order;
use LE_ACME2\Request\AbstractRequest;
use LE_ACME2\Response;
use LE_ACME2\Connector;
use LE_ACME2\Cache;
use LE_ACME2\Exception;
use LE_ACME2\Utilities;
class GetCertificate extends AbstractRequest {
protected $_order;
protected $_orderResponse;
private $_alternativeUrl = null;
public function __construct(Order $order, Response\Order\AbstractOrder $orderResponse,
string $alternativeUrl = null
) {
$this->_order = $order;
$this->_orderResponse = $orderResponse;
if($alternativeUrl !== null) {
$this->_alternativeUrl = $alternativeUrl;
}
}
/**
* @return Response\AbstractResponse|Response\Order\GetCertificate
* @throws Exception\InvalidResponse
* @throws Exception\RateLimitReached
*/
public function getResponse() : Response\AbstractResponse {
$url = $this->_alternativeUrl === null ?
$this->_orderResponse->getCertificate() :
$this->_alternativeUrl;
$kid = Utilities\RequestSigner::KID(
null,
Cache\AccountResponse::getInstance()->get($this->_order->getAccount())->getLocation(),
$url,
Cache\NewNonceResponse::getInstance()->get()->getNonce(),
$this->_order->getAccount()->getKeyDirectoryPath()
);
$result = Connector\Connector::getInstance()->request(
Connector\Connector::METHOD_POST,
$url,
$kid
);
return new Response\Order\GetCertificate($result);
}
}

View File

@@ -0,0 +1,58 @@
<?php
namespace LE_ACME2\Request\Order;
defined('ABSPATH') or die();
use LE_ACME2\Response;
use LE_ACME2\Request\AbstractRequest;
use LE_ACME2\Connector;
use LE_ACME2\Cache;
use LE_ACME2\Exception;
use LE_ACME2\Struct;
use LE_ACME2\Utilities;
class RevokeCertificate extends AbstractRequest {
protected $_certificateBundle;
protected $_reason;
public function __construct(Struct\CertificateBundle $certificateBundle, $reason) {
$this->_certificateBundle = $certificateBundle;
$this->_reason = $reason;
}
/**
* @return Response\AbstractResponse|Response\Order\RevokeCertificate
* @throws Exception\InvalidResponse
* @throws Exception\RateLimitReached
*/
public function getResponse() : Response\AbstractResponse {
$certificate = file_get_contents($this->_certificateBundle->path . $this->_certificateBundle->certificate);
preg_match('~-----BEGIN\sCERTIFICATE-----(.*)-----END\sCERTIFICATE-----~s', $certificate, $matches);
$certificate = trim(Utilities\Base64::UrlSafeEncode(base64_decode(trim($matches[1]))));
$payload = [
'certificate' => $certificate,
'reason' => $this->_reason
];
$jwk = Utilities\RequestSigner::JWKString(
$payload,
Cache\DirectoryResponse::getInstance()->get()->getRevokeCert(),
Cache\NewNonceResponse::getInstance()->get()->getNonce(),
$this->_certificateBundle->path,
$this->_certificateBundle->private
);
$result = Connector\Connector::getInstance()->request(
Connector\Connector::METHOD_POST,
Cache\DirectoryResponse::getInstance()->get()->getRevokeCert(),
$jwk
);
return new Response\Order\RevokeCertificate($result);
}
}

View File

@@ -0,0 +1,72 @@
<?php
namespace LE_ACME2\Response;
defined('ABSPATH') or die();
use LE_ACME2\Exception;
use LE_ACME2\Connector\RawResponse;
abstract class AbstractResponse {
protected $_raw = NULL;
protected $_pattern_header_location = '/^Location: (\S+)$/i';
/**
* AbstractResponse constructor.
*
* @param RawResponse $raw
* @throws Exception\InvalidResponse
* @throws Exception\RateLimitReached
*/
public function __construct(RawResponse $raw) {
$this->_raw = $raw;
if($this->_isRateLimitReached()) {
$detail = "";
if(isset($raw->body['type']) && $raw->body['type'] == 'urn:ietf:params:acme:error:rateLimited') {
$detail = $raw->body['detail'];
}
throw new Exception\RateLimitReached($raw->request, $detail);
}
$result = $this->_isValid();
if(!$result) {
$responseStatus = $this->_preg_match_headerLine('/^HTTP\/.* [0-9]{3,} /i');
throw new Exception\InvalidResponse(
$raw,
$responseStatus ? $responseStatus[1] : null
);
}
}
protected function _preg_match_headerLine(string $pattern) : ?array {
foreach($this->_raw->header as $line) {
if(preg_match($pattern, $line, $matches) === 1)
return $matches;
}
return null;
}
protected function _isRateLimitReached() : bool {
return $this->_preg_match_headerLine('/^HTTP\/.* 429/i') !== null;
}
protected function _isValid() : bool {
return $this->_preg_match_headerLine('/^HTTP\/.* 201/i') !== null || //Created
$this->_preg_match_headerLine('/^HTTP\/.* 200/i') !== null ||
$this->_preg_match_headerLine('/^HTTP\/.* 204/i') !== null;
}
public function getRaw() : RawResponse {
return $this->_raw;
}
}

View File

@@ -0,0 +1,18 @@
<?php
namespace LE_ACME2\Response\Account;
defined('ABSPATH') or die();
use LE_ACME2\Response\AbstractResponse;
abstract class AbstractAccount extends AbstractResponse {
const STATUS_VALID = 'valid';
public function getLocation() : string {
$matches = $this->_preg_match_headerLine($this->_pattern_header_location);
return trim($matches[1]);
}
}

View File

@@ -0,0 +1,33 @@
<?php
namespace LE_ACME2\Response\Account;
defined('ABSPATH') or die();
use LE_ACME2\Response\AbstractResponse;
abstract class AbstractLocation extends AbstractResponse {
public function getKey() : string {
return $this->_raw->body['key'];
}
public function getContact() : string {
return $this->_raw->body['contact'];
}
public function getAgreement() : string {
return $this->_raw->body['agreement'];
}
public function getInitialIP() : string {
return $this->_raw->body['initialIp'];
}
public function getCreatedAt() : string {
return $this->_raw->body['createdAt'];
}
public function getStatus() : string {
return $this->_raw->body['status'];
}
}

View File

@@ -0,0 +1,8 @@
<?php
namespace LE_ACME2\Response\Account;
defined('ABSPATH') or die();
use LE_ACME2\Response\AbstractResponse;
class ChangeKeys extends AbstractResponse {}

View File

@@ -0,0 +1,6 @@
<?php
namespace LE_ACME2\Response\Account;
defined('ABSPATH') or die();
class Create extends AbstractAccount {}

View File

@@ -0,0 +1,6 @@
<?php
namespace LE_ACME2\Response\Account;
defined('ABSPATH') or die();
class Deactivate extends AbstractLocation {}

View File

@@ -0,0 +1,6 @@
<?php
namespace LE_ACME2\Response\Account;
defined('ABSPATH') or die();
class Get extends AbstractAccount {}

View File

@@ -0,0 +1,6 @@
<?php
namespace LE_ACME2\Response\Account;
defined('ABSPATH') or die();
class GetData extends AbstractLocation {}

View File

@@ -0,0 +1,6 @@
<?php
namespace LE_ACME2\Response\Account;
defined('ABSPATH') or die();
class Update extends AbstractLocation {}

View File

@@ -0,0 +1,36 @@
<?php
namespace LE_ACME2\Response\Authorization;
defined('ABSPATH') or die();
use LE_ACME2\Response\AbstractResponse;
use LE_ACME2\Connector\RawResponse;
use LE_ACME2\Exception;
class AbstractAuthorization extends AbstractResponse {
/**
* AbstractAuthorization constructor.
* @param RawResponse $raw
* @throws Exception\InvalidResponse
* @throws Exception\RateLimitReached
* @throws Exception\ExpiredAuthorization
*/
public function __construct(RawResponse $raw) {
parent::__construct($raw);
}
/**
* @return bool
* @throws Exception\ExpiredAuthorization
*/
protected function _isValid() : bool {
if($this->_preg_match_headerLine('/^HTTP\/.* 404/i') !== null) {
throw new Exception\ExpiredAuthorization();
}
return parent::_isValid();
}
}

View File

@@ -0,0 +1,39 @@
<?php
namespace LE_ACME2\Response\Authorization;
defined('ABSPATH') or die();
use LE_ACME2\Response\Authorization\Struct;
class Get extends AbstractAuthorization {
public function getIdentifier() : Struct\Identifier {
return new Struct\Identifier($this->_raw->body['identifier']['type'], $this->_raw->body['identifier']['value']);
}
public function getStatus() : string {
return $this->_raw->body['status'];
}
public function getExpires() : string {
return $this->_raw->body['expires'];
}
public function getChallenges() : array {
return $this->_raw->body['challenges'];
}
/**
* @param $type
* @return Struct\Challenge
*/
public function getChallenge(string $type) : Struct\Challenge {
foreach($this->getChallenges() as $challenge) {
if($type == $challenge['type'])
return new Struct\Challenge($challenge['type'], $challenge['status'], $challenge['url'], $challenge['token']);
}
throw new \RuntimeException('No challenge found with given type');
}
}

View File

@@ -0,0 +1,6 @@
<?php
namespace LE_ACME2\Response\Authorization;
defined('ABSPATH') or die();
class Start extends AbstractAuthorization {}

View File

@@ -0,0 +1,25 @@
<?php
namespace LE_ACME2\Response\Authorization\Struct;
defined('ABSPATH') or die();
class Challenge {
const STATUS_PROGRESSING = 'processing';
const STATUS_PENDING = 'pending';
const STATUS_VALID = 'valid';
const STATUS_INVALID = 'invalid';
public $type;
public $status;
public $url;
public $token;
public function __construct(string $type, string $status, string $url, string $token) {
$this->type = $type;
$this->status = $status;
$this->url = $url;
$this->token = $token;
}
}

View File

@@ -0,0 +1,16 @@
<?php
namespace LE_ACME2\Response\Authorization\Struct;
defined('ABSPATH') or die();
class Identifier {
public $type;
public $value;
public function __construct(string $type, string $value) {
$this->type = $type;
$this->value = $value;
}
}

View File

@@ -0,0 +1,39 @@
<?php
namespace LE_ACME2\Response;
defined('ABSPATH') or die();
class GetDirectory extends AbstractResponse {
public function getKeyChange() : string {
return $this->_raw->body['keyChange'];
}
public function getNewAccount() : string {
return $this->_raw->body['newAccount'];
}
public function getNewNonce() : string {
return $this->_raw->body['newNonce'];
}
public function getNewOrder() : string {
return $this->_raw->body['newOrder'];
}
public function getRevokeCert() : string {
return $this->_raw->body['revokeCert'];
}
public function getTermsOfService() : string {
return $this->_raw->body['meta']['termsOfService'];
}
public function getWebsite() : string {
return $this->_raw->body['meta']['website'];
}
public function getCaaIdentities() : string {
return $this->_raw->body['meta']['caaIdentities'];
}
}

View File

@@ -0,0 +1,19 @@
<?php
namespace LE_ACME2\Response;
defined('ABSPATH') or die();
class GetNewNonce extends AbstractResponse {
protected $_pattern = '/^Replay\-Nonce: (\S+)$/i';
protected function _isValid() : bool {
return $this->_preg_match_headerLine($this->_pattern) !== null;
}
public function getNonce() : string {
$matches = $this->_preg_match_headerLine($this->_pattern);
return trim($matches[1]);
}
}

View File

@@ -0,0 +1,68 @@
<?php
namespace LE_ACME2\Response\Order;
defined('ABSPATH') or die();
use LE_ACME2\Response\AbstractResponse;
use LE_ACME2\Exception;
abstract class AbstractOrder extends AbstractResponse {
const STATUS_PENDING = 'pending';
const STATUS_VALID = 'valid';
const STATUS_READY = 'ready';
const STATUS_INVALID = 'invalid';
public function getLocation() : string {
$matches = $this->_preg_match_headerLine($this->_pattern_header_location);
return trim($matches[1]);
}
public function getStatus() : string {
return $this->_raw->body['status'];
}
public function getExpires() : string {
return $this->_raw->body['expires'];
}
public function getIdentifiers() : array {
return $this->_raw->body['identifiers'];
}
public function getAuthorizations() : array {
return $this->_raw->body['authorizations'];
}
public function getFinalize() : string {
return $this->_raw->body['finalize'];
}
public function getCertificate() : string {
return $this->_raw->body['certificate'];
}
/**
* @return bool
* @throws Exception\StatusInvalid
*/
protected function _isValid(): bool {
if(!parent::_isValid()) {
return false;
}
if(
$this->getStatus() == AbstractOrder::STATUS_INVALID
) {
throw new Exception\StatusInvalid('Order has status "' . AbstractOrder::STATUS_INVALID . '"'.
'. Probably all authorizations have failed. ' . PHP_EOL .
'Please see: ' . $this->getLocation() . PHP_EOL .
'Continue by using $order->clear() after getting rid of the problem'
);
}
return true;
}
}

View File

@@ -0,0 +1,6 @@
<?php
namespace LE_ACME2\Response\Order;
defined('ABSPATH') or die();
class Create extends AbstractOrder {}

View File

@@ -0,0 +1,6 @@
<?php
namespace LE_ACME2\Response\Order;
defined('ABSPATH') or die();
class Finalize extends AbstractOrder {}

View File

@@ -0,0 +1,27 @@
<?php
namespace LE_ACME2\Response\Order;
defined('ABSPATH') or die();
use LE_ACME2\Connector\RawResponse;
use LE_ACME2\Exception;
class Get extends AbstractOrder {
/**
* Get constructor.
*
* @param RawResponse $raw
* @param $orderURL
* @throws Exception\InvalidResponse
* @throws Exception\RateLimitReached
*/
public function __construct(RawResponse $raw, string $orderURL) {
// Dirty fix: Header of response "Get" does not contain an order url, instead of response "Create"
// Is needed on production server, not on staging server - tested: 12.04.2021
$raw->header[] = 'Location: ' . $orderURL;
parent::__construct($raw);
}
}

View File

@@ -0,0 +1,57 @@
<?php
namespace LE_ACME2\Response\Order;
defined('ABSPATH') or die();
use LE_ACME2\Response\AbstractResponse;
class GetCertificate extends AbstractResponse {
protected $_pattern = '~(-----BEGIN\sCERTIFICATE-----[\s\S]+?-----END\sCERTIFICATE-----)~i';
public function getCertificate() : string {
if(preg_match_all($this->_pattern, $this->_raw->body, $matches)) {
return $matches[0][0];
}
throw new \RuntimeException('Preg_match_all has returned false - invalid pattern?');
}
public function getIntermediate() : string {
if(preg_match_all($this->_pattern, $this->_raw->body, $matches)) {
$result = '';
for($i=1; $i<count($matches[0]); $i++) {
$result .= "\n" . $matches[0][$i];
}
return $result;
}
throw new \RuntimeException('Preg_match_all has returned false - invalid pattern?');
}
/**
* @return string[]
*/
public function getAlternativeLinks() : array {
$result = [];
foreach($this->_raw->header as $line) {
$matches = [];
preg_match_all('/^link: <(.*)>;rel="alternate"$/', $line, $matches);
if(isset($matches[1][0])) {
$result[] = $matches[1][0];
}
}
return $result;
}
}

View File

@@ -0,0 +1,8 @@
<?php
namespace LE_ACME2\Response\Order;
defined('ABSPATH') or die();
use LE_ACME2\Response\AbstractResponse;
class RevokeCertificate extends AbstractResponse {}

View File

@@ -0,0 +1,19 @@
<?php
namespace LE_ACME2;
defined('ABSPATH') or die();
trait SingletonTrait {
private static $_instance = NULL;
/**
* @return static
*/
final public static function getInstance(): self {
if( self::$_instance === NULL ) {
self::$_instance = new self();
}
return self::$_instance;
}
}

View File

@@ -0,0 +1,22 @@
<?php
namespace LE_ACME2\Struct;
defined('ABSPATH') or die();
class CertificateBundle {
public $path;
public $private;
public $certificate;
public $intermediate;
public $expireTime;
public function __construct(string $path, string $private, string $certificate, string $intermediate, int $expireTime) {
$this->path = $path;
$this->private = $private;
$this->certificate = $certificate;
$this->intermediate = $intermediate;
$this->expireTime = $expireTime;
}
}

View File

@@ -0,0 +1,40 @@
<?php
namespace LE_ACME2\Struct;
defined('ABSPATH') or die();
use LE_ACME2\Account;
use LE_ACME2\Utilities;
class ChallengeAuthorizationKey {
private $_account;
public function __construct(Account $account) {
$this->_account = $account;
}
public function get(string $token) : string {
return $token . '.' . $this->_getDigest();
}
public function getEncoded(string $token) : string {
return Utilities\Base64::UrlSafeEncode(
hash('sha256', $this->get($token), true)
);
}
private function _getDigest() : string {
$privateKey = openssl_pkey_get_private(file_get_contents($this->_account->getKeyDirectoryPath() . 'private.pem'));
$details = openssl_pkey_get_details($privateKey);
$header = array(
"e" => Utilities\Base64::UrlSafeEncode($details["rsa"]["e"]),
"kty" => "RSA",
"n" => Utilities\Base64::UrlSafeEncode($details["rsa"]["n"])
);
return Utilities\Base64::UrlSafeEncode(hash('sha256', json_encode($header), true));
}
}

View File

@@ -0,0 +1,33 @@
<?php
namespace LE_ACME2\Utilities;
defined('ABSPATH') or die();
class Base64 {
/**
* Encodes a string input to a base64 encoded string which is URL safe.
*
* @param string $input The input string to encode.
* @return string Returns a URL safe base64 encoded string.
*/
public static function UrlSafeEncode(string $input) : string {
return str_replace('=', '', strtr(base64_encode($input), '+/', '-_'));
}
/**
* Decodes a string that is URL safe base64 encoded.
*
* @param string $input The encoded input string to decode.
* @return string Returns the decoded input string.
*/
public static function UrlSafeDecode(string $input) : string {
$remainder = strlen($input) % 4;
if ($remainder) {
$padlen = 4 - $remainder;
$input .= str_repeat('=', $padlen);
}
return base64_decode(strtr($input, '-_', '+/'));
}
}

View File

@@ -0,0 +1,89 @@
<?php
namespace LE_ACME2\Utilities;
defined('ABSPATH') or die();
use LE_ACME2\Order;
use LE_ACME2\Exception\OpenSSLException;
class Certificate {
protected static $_featureOCSPMustStapleEnabled = false;
public static function enableFeatureOCSPMustStaple() {
self::$_featureOCSPMustStapleEnabled = true;
}
public static function disableFeatureOCSPMustStaple() {
self::$_featureOCSPMustStapleEnabled = false;
}
/**
* @param Order $order
* @return string
* @throws OpenSSLException
*/
public static function generateCSR(Order $order) : string {
$dn = [
"commonName" => $order->getSubjects()[0]
];
$san = implode(",", array_map(function ($dns) {
return "DNS:" . $dns;
}, $order->getSubjects())
);
$configFilePath = $order->getKeyDirectoryPath() . 'csr_config';
$config = 'HOME = .
RANDFILE = ' . $order->getKeyDirectoryPath() . '.rnd
[ req ]
default_bits = 4096
default_keyfile = privkey.pem
distinguished_name = req_distinguished_name
req_extensions = v3_req
[ req_distinguished_name ]
countryName = Country Name (2 letter code)
[ v3_req ]
basicConstraints = CA:FALSE
subjectAltName = ' . $san . '
keyUsage = nonRepudiation, digitalSignature, keyEncipherment';
if(self::$_featureOCSPMustStapleEnabled) {
$config .= PHP_EOL . 'tlsfeature=status_request';
}
file_put_contents($configFilePath, $config);
$privateKey = openssl_pkey_get_private(
file_get_contents($order->getKeyDirectoryPath() . 'private.pem')
);
if($privateKey === false) {
throw new OpenSSLException('openssl_pkey_get_private');
}
$csr = openssl_csr_new(
$dn,
$privateKey,
[
'config' => $configFilePath,
'digest_alg' => 'sha256'
]
);
if($csr === false) {
throw new OpenSSLException('openssl_csr_new');
}
if(!openssl_csr_export($csr, $csr)) {
throw new OpenSSLException('openssl_csr_export');
}
unlink($configFilePath);
return $csr;
}
}

View File

@@ -0,0 +1,66 @@
<?php
namespace LE_ACME2\Utilities;
defined('ABSPATH') or die();
class KeyGenerator {
/**
* Generates a new RSA keypair and saves both keys to a new file.
*
* @param string $directory The directory in which to store the new keys.
* @param string $privateKeyFile The filename for the private key file.
* @param string $publicKeyFile The filename for the public key file.
*/
public static function RSA(string $directory, string $privateKeyFile = 'private.pem', string $publicKeyFile = 'public.pem') {
$res = openssl_pkey_new([
"private_key_type" => OPENSSL_KEYTYPE_RSA,
"private_key_bits" => 4096,
]);
if(!openssl_pkey_export($res, $privateKey))
throw new \RuntimeException("RSA keypair export failed!");
$details = openssl_pkey_get_details($res);
file_put_contents($directory . $privateKeyFile, $privateKey);
file_put_contents($directory . $publicKeyFile, $details['key']);
if(PHP_MAJOR_VERSION < 8) {
// deprecated after PHP 8.0.0 and not needed anymore
openssl_pkey_free($res);
}
}
/**
* Generates a new EC prime256v1 keypair and saves both keys to a new file.
*
* @param string $directory The directory in which to store the new keys.
* @param string $privateKeyFile The filename for the private key file.
* @param string $publicKeyFile The filename for the public key file.
*/
public static function EC(string $directory, string $privateKeyFile = 'private.pem', string $publicKeyFile = 'public.pem') {
if (version_compare(PHP_VERSION, '7.1.0') == -1)
throw new \RuntimeException("PHP 7.1+ required for EC keys");
$res = openssl_pkey_new([
"private_key_type" => OPENSSL_KEYTYPE_EC,
"curve_name" => "prime256v1",
]);
if(!openssl_pkey_export($res, $privateKey))
throw new \RuntimeException("EC keypair export failed!");
$details = openssl_pkey_get_details($res);
file_put_contents($directory . $privateKeyFile, $privateKey);
file_put_contents($directory . $publicKeyFile, $details['key']);
if(PHP_MAJOR_VERSION < 8) {
// deprecated after PHP 8.0.0 and not needed anymore
openssl_pkey_free($res);
}
}
}

View File

@@ -0,0 +1,65 @@
<?php
namespace LE_ACME2\Utilities;
defined('ABSPATH') or die();
use LE_ACME2\SingletonTrait;
class Logger {
use SingletonTrait;
const LEVEL_DISABLED = 0;
const LEVEL_INFO = 1;
const LEVEL_DEBUG = 2;
private function __construct() {}
protected $_desiredLevel = self::LEVEL_DISABLED;
public function setDesiredLevel(int $desiredLevel) {
$this->_desiredLevel = $desiredLevel;
}
/**
* @param int $level
* @param string $message
* @param string|array|object $data
*/
public function add(int $level, string $message, $data = array()) {
if($level > $this->_desiredLevel)
return;
$e = new \Exception();
$trace = $e->getTrace();
unset($trace[0]);
$output = '<b>' . date('d-m-Y H:i:s') . ': ' . $message . '</b><br>' . "\n";
if($this->_desiredLevel == self::LEVEL_DEBUG) {
$step = 0;
foreach ($trace as $traceItem) {
if(!isset($traceItem['class']) || !isset($traceItem['function'])) {
continue;
}
$output .= 'Trace #' . $step . ': ' . $traceItem['class'] . '::' . $traceItem['function'] . '<br/>' . "\n";
$step++;
}
if ((is_array($data) && count($data) > 0) || !is_array($data))
$output .= "\n" .'<br/>Data:<br/>' . "\n" . '<pre>' . var_export($data, true) . '</pre>';
$output .= '<br><br>' . "\n\n";
}
if(PHP_SAPI == 'cli') {
$output = strip_tags($output);
}
echo $output;
}
}

View File

@@ -0,0 +1,113 @@
<?php
namespace LE_ACME2\Utilities;
defined('ABSPATH') or die();
class RequestSigner {
/**
* Generates a JSON Web Key signature to attach to the request.
*
* @param array $payload The payload to add to the signature.
* @param string $url The URL to use in the signature.
* @param string $nonce
* @param string $privateKeyDir The directory to get the private key from. Default to the account keys directory given in the constructor. (optional)
* @param string $privateKeyFile The private key to sign the request with. Defaults to 'private.pem'. (optional)
*
* @return array Returns an array containing the signature.
*/
public static function JWK(array $payload, string $url, string $nonce, string $privateKeyDir, string $privateKeyFile = 'private.pem') : array {
Logger::getInstance()->add(Logger::LEVEL_DEBUG, 'JWK sign request for ' . $url, $payload);
$privateKey = openssl_pkey_get_private(file_get_contents($privateKeyDir . $privateKeyFile));
$details = openssl_pkey_get_details($privateKey);
$protected = [
"alg" => "RS256",
"jwk" => [
"kty" => "RSA",
"n" => Base64::UrlSafeEncode($details["rsa"]["n"]),
"e" => Base64::UrlSafeEncode($details["rsa"]["e"]),
],
"nonce" => $nonce,
"url" => $url
];
$payload64 = Base64::UrlSafeEncode(str_replace('\\/', '/', json_encode($payload)));
$protected64 = Base64::UrlSafeEncode(json_encode($protected));
openssl_sign($protected64.'.'.$payload64, $signed, $privateKey, "SHA256");
$signed64 = Base64::UrlSafeEncode($signed);
$data = array(
'protected' => $protected64,
'payload' => $payload64,
'signature' => $signed64
);
return $data;
}
/**
* Generates a JSON Web Key signature to attach to the request.
*
* @param array $payload The payload to add to the signature.
* @param string $url The URL to use in the signature.
* @param string $nonce
* @param string $privateKeyDir The directory to get the private key from. Default to the account keys directory given in the constructor. (optional)
* @param string $privateKeyFile The private key to sign the request with. Defaults to 'private.pem'. (optional)
*
* @return string Returns a JSON encoded string containing the signature.
*/
public static function JWKString(array $payload, string $url, string $nonce, string $privateKeyDir, string $privateKeyFile = 'private.pem') : string {
$jwk = self::JWK($payload, $url, $nonce, $privateKeyDir, $privateKeyFile);
return json_encode($jwk);
}
/**
* Generates a Key ID signature to attach to the request.
*
* @param array|null $payload The payload to add to the signature.
* @param string $kid The Key ID to use in the signature.
* @param string $url The URL to use in the signature.
* @param string $nonce
* @param string $privateKeyDir The directory to get the private key from.
* @param string $privateKeyFile The private key to sign the request with. Defaults to 'private.pem'. (optional)
*
* @return string Returns a JSON encoded string containing the signature.
*/
public static function KID(?array $payload, string $kid, string $url, string $nonce, string $privateKeyDir, string $privateKeyFile = 'private.pem') : string {
Logger::getInstance()->add(Logger::LEVEL_DEBUG, 'KID sign request for ' . $url, $payload);
$privateKey = openssl_pkey_get_private(file_get_contents($privateKeyDir . $privateKeyFile));
// TODO: unused - $details = openssl_pkey_get_details($privateKey);
$protected = [
"alg" => "RS256",
"kid" => $kid,
"nonce" => $nonce,
"url" => $url
];
Logger::getInstance()->add(Logger::LEVEL_DEBUG, 'KID: ready to sign request for: ' . $url, $protected);
$payload = $payload === null ? "" : str_replace('\\/', '/', json_encode($payload));
$payload64 = Base64::UrlSafeEncode($payload);
$protected64 = Base64::UrlSafeEncode(json_encode($protected));
openssl_sign($protected64.'.'.$payload64, $signed, $privateKey, "SHA256");
$signed64 = Base64::UrlSafeEncode($signed);
$data = [
'protected' => $protected64,
'payload' => $payload64,
'signature' => $signed64
];
return json_encode($data);
}
}

View File

@@ -0,0 +1,15 @@
<?php
namespace LE_ACME2Tests;
defined('ABSPATH') or die();
use PHPUnit\Framework\TestCase;
use LE_ACME2;
abstract class AbstractTest extends TestCase {
public function __construct() {
parent::__construct();
LE_ACME2\Connector\Connector::getInstance()->useStagingServer(true);
}
}

View File

@@ -0,0 +1,177 @@
<?php
namespace LE_ACME2Tests;
defined('ABSPATH') or die();
use LE_ACME2\Exception\InvalidResponse;
/**
* @covers \LE_ACME2\Account
*/
class AccountTest extends AbstractTest {
private $_commonKeyDirectoryPath;
private $_email;
public function __construct() {
parent::__construct();
$this->_commonKeyDirectoryPath = TestHelper::getInstance()->getTempPath() . 'le-storage/';
$this->_email = 'le_acme2_php_client@test.com';
}
public function testNonExistingCommonKeyDirectoryPath() {
$this->assertTrue(\LE_ACME2\Account::getCommonKeyDirectoryPath() === null);
$notExistingPath = TestHelper::getInstance()->getTempPath() . 'should-not-exist/';
$this->expectException(\RuntimeException::class);
\LE_ACME2\Account::setCommonKeyDirectoryPath($notExistingPath);
}
public function testCommonKeyDirectoryPath() {
if(!file_exists($this->_commonKeyDirectoryPath)) {
mkdir($this->_commonKeyDirectoryPath);
}
\LE_ACME2\Account::setCommonKeyDirectoryPath($this->_commonKeyDirectoryPath);
$this->assertTrue(
\LE_ACME2\Account::getCommonKeyDirectoryPath() === $this->_commonKeyDirectoryPath
);
}
public function testNonExisting() {
if(\LE_ACME2\Account::exists($this->_email)) {
$this->markTestSkipped('Skipped: Account does already exist');
}
$this->assertTrue(!\LE_ACME2\Account::exists($this->_email));
$this->expectException(\RuntimeException::class);
\LE_ACME2\Account::get($this->_email);
}
public function testCreate() {
if(\LE_ACME2\Account::exists($this->_email)) {
// Skipping account modification tests, when the account already exists
// to reduce the LE api usage while developing
TestHelper::getInstance()->setSkipAccountModificationTests(true);
$this->markTestSkipped('Account modifications skipped: Account does already exist');
}
$this->assertTrue(!\LE_ACME2\Account::exists($this->_email));
$account = \LE_ACME2\Account::create($this->_email);
$this->assertTrue(is_object($account));
$this->assertTrue($account->getEmail() === $this->_email);
$account = \LE_ACME2\Account::get($this->_email);
$this->assertTrue(is_object($account));
$result = $account->getData();
$this->assertTrue($result->getStatus() === \LE_ACME2\Response\Account\AbstractAccount::STATUS_VALID);
}
public function testInvalidCreate() {
if(TestHelper::getInstance()->shouldSkipAccountModificationTests()) {
$this->expectNotToPerformAssertions();
return;
}
$this->expectException(InvalidResponse::class);
$this->expectExceptionMessage(
'Invalid response received: ' .
'urn:ietf:params:acme:error:invalidEmail' .
' - ' .
'Error creating new account :: invalid contact domain. Contact emails @example.org are forbidden'
);
\LE_ACME2\Account::create('test@example.org');
}
public function testModification() {
if(TestHelper::getInstance()->shouldSkipAccountModificationTests()) {
$this->expectNotToPerformAssertions();
return;
}
$account = \LE_ACME2\Account::get($this->_email);
$this->assertTrue(is_object($account));
$keyDirectoryPath = $account->getKeyDirectoryPath();
$newEmail = 'new-' . $this->_email;
// An email from example.org is not allowed
$result = $account->update('test@example.org');
$this->assertTrue($result === false);
$result = $account->update($newEmail);
$this->assertTrue($result === true);
$this->assertTrue($account->getKeyDirectoryPath() !== $keyDirectoryPath);
$this->assertTrue(file_exists($account->getKeyDirectoryPath()));
$result = $account->update($this->_email);
$this->assertTrue($result === true);
$result = $account->changeKeys();
$this->assertTrue($result === true);
}
public function testDeactivation() {
if(TestHelper::getInstance()->shouldSkipAccountModificationTests()) {
$this->expectNotToPerformAssertions();
return;
}
$account = \LE_ACME2\Account::get($this->_email);
$this->assertTrue(is_object($account));
$result = $account->deactivate();
$this->assertTrue($result === true);
// The account is already deactivated
$result = $account->deactivate();
$this->assertTrue($result === false);
// The account is already deactivated
$result = $account->changeKeys();
$this->assertTrue($result === false);
// The account is already deactivated
$this->expectException(\LE_ACME2\Exception\InvalidResponse::class);
$account->getData();
}
public function testCreationAfterDeactivation() {
if(TestHelper::getInstance()->shouldSkipAccountModificationTests()) {
$this->expectNotToPerformAssertions();
return;
}
$account = \LE_ACME2\Account::get($this->_email);
$this->assertTrue(is_object($account));
system('rm -R ' . $account->getKeyDirectoryPath());
$this->assertTrue(!\LE_ACME2\Account::exists($this->_email));
$account = \LE_ACME2\Account::create($this->_email);
$this->assertTrue(is_object($account));
}
public function test() {
$account = \LE_ACME2\Account::get($this->_email);
$this->assertTrue(is_object($account));
}
}

View File

@@ -0,0 +1,41 @@
<?php
namespace LE_ACME2Tests\Authorizer;
defined('ABSPATH') or die();
use LE_ACME2Tests\AbstractTest;
use LE_ACME2Tests\TestHelper;
/**
* @covers \LE_ACME2\Authorizer\HTTP
*/
class HTTPTest extends AbstractTest {
private $_directoryPath;
public function __construct() {
parent::__construct();
$this->_directoryPath = TestHelper::getInstance()->getTempPath() . 'acme-challenges/';
}
public function testNonExistingDirectoryPath() {
$this->assertTrue(\LE_ACME2\Authorizer\HTTP::getDirectoryPath() === null);
$this->expectException(\RuntimeException::class);
\LE_ACME2\Authorizer\HTTP::setDirectoryPath(TestHelper::getInstance()->getNonExistingPath());
}
public function testDirectoryPath() {
if(!file_exists($this->_directoryPath)) {
mkdir($this->_directoryPath);
}
\LE_ACME2\Authorizer\HTTP::setDirectoryPath($this->_directoryPath);
$this->assertTrue(
\LE_ACME2\Authorizer\HTTP::getDirectoryPath() === $this->_directoryPath
);
}
}

View File

@@ -0,0 +1,42 @@
<?php
namespace LE_ACME2Tests;
defined('ABSPATH') or die();
use LE_ACME2\SingletonTrait;
class TestHelper {
private $_tempPath;
private $_nonExistingPath;
use SingletonTrait;
private function __construct() {
$projectPath = realpath($_SERVER[ 'PWD' ]) . '/';
$this->_tempPath = $projectPath . 'temp/';
if( !file_exists($this->_tempPath) ) {
mkdir($this->_tempPath);
}
$this->_nonExistingPath = $this->getTempPath() . 'should-not-exist/';
}
public function getTempPath() : string {
return $this->_tempPath;
}
public function getNonExistingPath() : string {
return $this->_nonExistingPath;
}
private $_skipAccountModificationTests = false;
public function setSkipAccountModificationTests(bool $value) : void {
$this->_skipAccountModificationTests = $value;
}
public function shouldSkipAccountModificationTests() : bool {
return $this->_skipAccountModificationTests;
}
}