diff --git a/.gitignore b/.gitignore index c422267..1981ce1 100644 --- a/.gitignore +++ b/.gitignore @@ -1,4 +1,6 @@ +.idea/ composer.phar +composer.lock /vendor/ # Commit your application's lock file http://getcomposer.org/doc/01-basic-usage.md#composer-lock-the-lock-file diff --git a/README.md b/README.md index 2837832..6574c1c 100644 --- a/README.md +++ b/README.md @@ -1,2 +1,6 @@ # acme2 Another PHP client for acme protocal (version 2) implementation, used for generating letsencrypt's free ssl certificate. + +## docs +* [English](https://github.com/stonemax/acme2/blob/develop/docs/README.md) +* [简体中文](https://github.com/stonemax/acme2/blob/develop/docs/README-ZH.md) \ No newline at end of file diff --git a/VERSION b/VERSION new file mode 100644 index 0000000..afaf360 --- /dev/null +++ b/VERSION @@ -0,0 +1 @@ +1.0.0 \ No newline at end of file diff --git a/composer.json b/composer.json index f00c0d7..b46692c 100644 --- a/composer.json +++ b/composer.json @@ -1,7 +1,7 @@ { "name": "stonemax/acme2", - "description": "Another PHP client for acme protocal (version 2) implementation, used for generating letsencrypt's free ssl certificate.", - "type": "library", + "description": "Another PHP client for acme protocal (version 2) implementation, used for generating letsencrypt's free ssl certificates.", + "type": "project", "license": "MIT", "authors": [ { @@ -10,5 +10,10 @@ } ], "minimum-stability": "stable", - "require": {} + "require": {}, + "autoload": { + "psr-4": { + "stonemax\\acme2\\": "src/" + } + } } diff --git a/docs/README-ZH.md b/docs/README-ZH.md new file mode 100644 index 0000000..b3004ca --- /dev/null +++ b/docs/README-ZH.md @@ -0,0 +1,158 @@ +# ACME2 +stonemax/acme2 是一个简单的 PHP 工具,用于生成符合 ACME(Version 2) 协议的CA证书,目前主要用于 [Let's Encrypt](https://letsencrypt.org/) 的证书签发,同时支持 RSA 和 ECDSA 类型证书的签发。本工具仅用于生成证书,并不会如官方工具一样帮助您配置 Web Server 或者 DNS 记录,因此,在域名的校验过程中,无论是在 Web Server 上设置校验文件,还是设置 DNS 记录,都需要您自己处理,您可以手动处理,也可以通过代码中的钩子进行自动化处理。 + + +## 1. 当前版本 +stonemax/acme2 当前的版本是 `1.0.0`。 + + +## 2. 先决条件 +由于使用了命名空间、三元运算符简写形式等 PHP 特性,因此使用本工具的最低 PHP 版本要求为 5.4.0+(含5.4.0)。但是当您要生成 ECDSA 类型的证书时,需要的 PHP 版本最低为 7.1.0+(含7.1.0)。此外,我们还需要开启 openssl 扩展。 +虽然stonemax/acme2 使用了 composer 作为包管理器,但是仅将其作为文件的自动加载器使用,实际上并未使用任何外部依赖 + + +## 3. 安装 +将代码下载到某个文件夹下,进入此文件夹,在当前目录下执行以下命令即可完成安装: + +```bash +cd example-directory +git clone git@github.com:stonemax/acme2.git . + +composer install +``` + + +## 4. 使用 +在这里,我们将介绍 stonemax/acme2 中对外暴露的方法,通过认识这些方法,您就大致知道如何使用了,我们也提供了一份案例代码,位于 [examples/](https://github.com/stonemax/acme2/tree/develop/examples) 目录下。 + +#### 4.1. 初始化客户端 + +```php +$emailList = ['alert@example.com']; // 邮箱列表,在适当时机,Let's Encrypt 会发送邮件到此邮箱,例如:证书即将过期 +$storagePath = './data'; // 账户数据以及生成的证书存储的目录 +$staging = TRUE; // 是否使用 staging 环境 + + +$client = new Client($emailList, $storagePath, $staging); // 初始化客户端 +``` + +初始化一个客户端时,工具会自动生成一个 Let's Encrypt 账户,账户数据存储在 `$storagePath/account` 目录下,当您再次初始化客户端时,如果账户数据已经存在,则不会再创建新的账户。 + +#### 4.2. 账户相关方法 + +```php +$account = $client->getAccount(); // 获取账户实例 + +$account->updateAccountContact($emailList); // 更新账户的联系邮件 +$account->updateAccountKey(); // 重新生成 private/public 密钥对,并使用新的密钥对替换原有的 +$account->deactivateAccount(); // 销毁账户 +``` + +#### 4.3. 订单相关方法 +证书的生成,主要使用的就是订单的相关方法。 + +```php +/* 证书包含的域名及其验证信息 */ +$domainInfo = [ + CommonConstant::CHALLENGE_TYPE_HTTP => [ + 'abc.example.com' + ], + + CommonConstant::CHALLENGE_TYPE_DNS => [ + '*.www.example.com', + 'www.example.com', + ], +]; + +$algorithm = CommonConstant::KEY_PAIR_TYPE_RSA; // 生成 RSA 类型的证书,使用 `CommonConstant::KEY_PAIR_TYPE_EC` 生成 ECDSA 证书 +$renew = FALSE; // 是否重新生成证书,一般用于证书快过期时,用于证书续期(实际上是重新生成了证书) + +$order = $client->getOrder($domainInfo, $algorithm, $renew); // 获取订单实例 + +$order->getPendingChallengeList(); // 获取 ChallengeService 实例列表,该列表中存储了域名验证的相关信息 +$order->getCertificateFile(); // 获取证书的相关信息,包含:证书位置、生成证书的密钥对文件位置、证书有效期 +$order->revokeCertificate($reason); // 吊销证书,证书吊销后就不能再使用了,需要重新生成 +``` + +#### 4.4. 证书验证相关方法 + +```php +$challengeList = $order->getPendingChallengeList(); + +foreach ($challengeList as $challenge) +{ + $challenge->getType(); // 认证方式,http-01 或者 dns-01 + $challenge->getCredential(); // 认证的具体信息,如果认证方式是 http-01,返回的数据中包含文件名和文件内容,如果是 dns-01,则包含 DNS 的记录值 + $challenge->verify(); // 验证域名,这是一个无限循环,直到证书验证成功才返回 +} +``` + + +## 5. 域名验证 +在生成证书时,Let's Encrypt 需要校验域名的所有权和有效性,主要有两种认证方式:域名下的文件校验(http-01)和域名 DNS 的 TXT 记录值认证(dns-01)。下文中,我们一律以 www.example.com 进行举例说明。 + +#### 5.1. HTTP 认证 +在这种认证方式下,需要在域名对应站点的相应位置放置一个特定文件,文件内包含特定的文件内容,Let's Encrypt 会访问该文件以校验域名。 +在此种情况下,`$challenge` 的相关信息如下所示。 + +```php +echo $challenge->getType(); + +/* output */ +'http-01' + + +print_r($challenge->getCredential()); + +/* output */ +[ + 'identifier' => 'www.example.com', + 'fileName' => 'RzMY-HDa1P0DwZalmRyB7wLBNI8fb11LkxdXzNrhA1Y', + 'fileContent' => 'RzMY-HDa1P0DwZalmRyB7wLBNI8fb11LkxdXzNrhA1Y.CNWZAGtAHIUpstBEckq9W_-0ZKxO-IbxF9Y8J_svbqo', +]; +``` + +此时,Let's Encrypt 会访问以下地址来进行域名认证:`http://www.example.com/.well-known/acme-challenge/HDa1P0DwZalmRyB7wLBNI8fb11LkxdXzNrhA1Y`。 + +#### 5.2. DNS 认证 +在这种方式下,需要在相应域名的 DNS 记录中增加 TXT 记录,这时 `$challenge` 的相关信息如下所示。 + +```php +echo $challenge->getType(); + +/* output */ +'dns-01' + + +print_r($challenge->getCredential()); + +/* output */ +[ + 'identifier' => 'www.example.com', + 'dnsContent' => 'xQwerUEsL8UVc6tIahwIVY4e8N5MAf1xhyY20AELurk', +]; +``` + +此时,需要增加主机记录为 `_acme-challenge.www.example.com`,类型为 TXT 的 DNS 记录,记录值为:`xQwerUEsL8UVc6tIahwIVY4e8N5MAf1xhyY20AELurk`。值得注意的是,记录的 TTL 值需要设置的尽量小,以便尽快生效。 + +#### 5.3. 通配符域名认证 +ACME2支持通配符证书的生成,但仅能使用 DNS 认证。拿 `*.www.example.com` 举例来说,当进行 DNS 认证时,其实是针对域名 `www.example.com` 进行校验的。下面针对 DNS 认证的各种情况做一个说明。 + +| 域名 | DNS 记录名 | 类型 | TTL | DNS 记录值 | +| ------------------ | -------------------------------- | ---- | --- | ------------------------------------------- | +| example.com | \_acme-challenge.example.com | TXT | 60 | xQwerUEsL8UVc6tIahwIVY4e8N5MAf1xhyY20AELurk | +| \*.example.com | \_acme-challenge.example.com | TXT | 60 | G2dOkzSjW3ohib5doPRDrz5a5l8JB1qU8CxURtzF7aE | +| www.example.com | \_acme-challenge.www.example.com | TXT | 60 | x1sc0pIwN5Sbqx0NO0QQeu8LxIfhbM2eTjwdWliYxF1 | +| \*.www.example.com | \_acme-challenge.www.example.com | TXT | 60 | eZ9ViY12gKfdruYHOO7Lu74ICXeQRMDLp5GuHLvPsf7 | + + +## 6. 完整例子 +stonemax/acme2 随代码附上了一个完整的例子,位于 [examples/](https://github.com/stonemax/acme2/tree/develop/examples) 目录下,也可以点击 [examples/example.php](https://github.com/stonemax/acme2/blob/develop/examples/example.php) 直接查看。 + + +## 7. 感谢 +[yourivw/LEClient](https://github.com/yourivw/LEClient) 项目对本项目有很大帮助,在此表示感谢! + + +## 8. 许可证 +此项目使用的是 MIT 许可证,[查看许可证信息](https://github.com/stonemax/acme2/blob/develop/LICENSE)。 diff --git a/docs/README.md b/docs/README.md new file mode 100644 index 0000000..ed03645 --- /dev/null +++ b/docs/README.md @@ -0,0 +1,165 @@ +# ACME2 + +stonemax/acme2 is a simple PHP tool to manage TLS certificates with ACME-compliant CAs, it's mainly used with let's Encrypt, support for both ESA and ECDSA certifacates. It will not set challenge file or DNS record for you, you can do these jobs manually, or automaticlly with you own code and hooks in stonemax/acme2. + + +## 1. Current Version +The current version is `1.0.0`. + + +## 2. Prerequisites +This version works with PHP-5.4.0 or higher, if you need to generate ECDSA certificates, PHP version should be 7.1.0 or higher. PHP need openssl extenson enabled in addition. +Although acme2 uses composer, but composer is just uesd as an autoloader, this porject has no any third party dependencies. + + +## 3. Install +Clone this project and run `compose install`. + +```bash +cd example-directory +git clone git@github.com:stonemax/acme2.git . + +composer install +``` + + +## 4. Usage +The basic methods and its necessary arguments are shown here. An example is supplied in [examples/](https://github.com/stonemax/acme2/tree/develop/examples). + +#### 4.1. Client + +```php +$emailList = ['alert@example.com']; // Email list as contact info +$storagePath = './data'; // Account data and certificates files will be stored here +$staging = TRUE; // Using stage environment or not + + +$client = new Client($emailList, $storagePath, $staging); // Initiating a client +``` + +After `Client` had been initiated, a Let's Encrypt account will be created and the account data will be placed in `$storagePath`. +When you reinitialize the client, the accout will not be created again. + +#### 4.2. Account + +```php +$account = $client->getAccount(); // Get account service instance + +$account->updateAccountContact($emailList); // Update account contact info with an email list +$account->updateAccountKey(); // Regenerate private/public key pair,the old will be replaced by the new +$account->deactivateAccount(); // Deactive the account +``` + +#### 4.3. Order +These methods bellow are mainly used for generating certificates. + +```php +/* Domains and challenges info */ +$domainInfo = [ + CommonConstant::CHALLENGE_TYPE_HTTP => [ + 'abc.example.com' + ], + + CommonConstant::CHALLENGE_TYPE_DNS => [ + '*.www.example.com', + 'www.example.com', + ], +]; + +$algorithm = CommonConstant::KEY_PAIR_TYPE_RSA; // Generate RSA certificates, `CommonConstant::KEY_PAIR_TYPE_EC` for ECDSA certificates +$renew = FALSE; // Renew certificates + +$order = $client->getOrder($domainInfo, $algorithm, $renew); // Get an order service instance + +$order->getPendingChallengeList(); // Get all authorization challenges for domains +$order->getCertificateFile(); // Get certificates, such as certificates path, private/public key pair path, valid time +$order->revokeCertificate($reason); // Revoke certificates, the certificaes ara unavailable after revoked +``` + +#### 4.4. Challenge + +```php +$challengeList = $order->getPendingChallengeList(); + +foreach ($challengeList as $challenge) +{ + $challenge->getType(); // Challenge type, http-01 or dns-01 + $challenge->getCredential(); // Challenge detail, http-01 with file name and file content, dns-01 with dns record value + $challenge->verify(); // Do verifying operation, this method will loop infinitely until verification passed +} +``` + + +## 5. Domain Verification +When generating a certificate, Let's Encrypt need to verify the ownership and validity of the domain. There are two types of verification: http-01, dns-01. +In the following, we take `www.example.com` as an example. + +#### 5.1. http-01 +As this type, Let's Encrypt will access a specific file under web server to verify domain. +As this time, the `$challenge` info is like bellow. + +```php +echo $challenge->getType(); + +/* output */ +'http-01' + + +print_r($challenge->getCredential()); + +/* output */ +[ + 'identifier' => 'www.example.com', + 'fileName' => 'RzMY-HDa1P0DwZalmRyB7wLBNI8fb11LkxdXzNrhA1Y', + 'fileContent' => 'RzMY-HDa1P0DwZalmRyB7wLBNI8fb11LkxdXzNrhA1Y.CNWZAGtAHIUpstBEckq9W_-0ZKxO-IbxF9Y8J_svbqo', +]; +``` + +With the aboved `$challenge` info, Let's Encrypt will access "http://www.example.com/.well-known/acme-challenge/HDa1P0DwZalmRyB7wLBNI8fb11LkxdXzNrhA1Y", and the file content will be expected as "RzMY-HDa1P0DwZalmRyB7wLBNI8fb11LkxdXzNrhA1Y.CNWZAGtAHIUpstBEckq9W_-0ZKxO-IbxF9Y8J_svbqo". + +#### 5.2. dns-01 +As this type, you should add a DNS TXT record for domain, Let's Encrypt will check domain's specific TXT record value for verification. +As this time, the `$challenge` info is like bellow. + +```php +echo $challenge->getType(); + +/* output */ +'dns-01' + + +print_r($challenge->getCredential()); + +/* output */ +[ + 'identifier' => 'www.example.com', + 'dnsContent' => 'xQwerUEsL8UVc6tIahwIVY4e8N5MAf1xhyY20AELurk', +]; +``` + +With the aboved `$challenge` info, you shuoud add a TXT record for domain `www.example.com`, the record name should be "_acme-challenge.www.example.com", the record value should be "xQwerUEsL8UVc6tIahwIVY4e8N5MAf1xhyY20AELurk". +It's worth noting that you should set TTL as short as possible to let the record take effect as soon as possible. + +#### 5.3. Wildcard domain verification +This tool supports generating certificates for wildcard domains. +A wildcard domain, like `*.www.example.com`, will be verified as `www.example.com`, this means the DNS record name should be `_acme-challenge.www.example.com`. +Here is a simple summary for dns-01 challenges about domain and DNS record. + +| Domain | DNS record name | Type | TTL | DNS record value(just examples) | +| ------------------ | -------------------------------- | ---- | --- | ------------------------------------------- | +| example.com | \_acme-challenge.example.com | TXT | 60 | xQwerUEsL8UVc6tIahwIVY4e8N5MAf1xhyY20AELurk | +| \*.example.com | \_acme-challenge.example.com | TXT | 60 | G2dOkzSjW3ohib5doPRDrz5a5l8JB1qU8CxURtzF7aE | +| www.example.com | \_acme-challenge.www.example.com | TXT | 60 | x1sc0pIwN5Sbqx0NO0QQeu8LxIfhbM2eTjwdWliYxF1 | +| \*.www.example.com | \_acme-challenge.www.example.com | TXT | 60 | eZ9ViY12gKfdruYHOO7Lu74ICXeQRMDLp5GuHLvPsf7 | + + +## 6. Full example +Project supplies a [full example](https://github.com/stonemax/acme2/blob/develop/examples/example.php) under directory [examples/](https://github.com/stonemax/acme2/tree/develop/examples). + + +## 7. Thanks +This Project had got a lot of inspirations from [yourivw/LEClient](https://github.com/yourivw/LEClient). Thanks! + + +## 8. License +This project is licensed under the MIT License, see the [LICENSE](https://github.com/stonemax/acme2/blob/develop/LICENSE) file for detail. \ No newline at end of file diff --git a/examples/example.php b/examples/example.php new file mode 100644 index 0000000..3ab0002 --- /dev/null +++ b/examples/example.php @@ -0,0 +1,69 @@ + + * @link https://github.com/stonemax/acme2 + * @copyright Copyright © 2018 Zhang Jinlong + * @license https://opensource.org/licenses/mit-license.php MIT License + */ + +include('../vendor/autoload.php'); + +use stonemax\acme2\Client; +use stonemax\acme2\constants\CommonConstant; + +$domainInfo = [ + CommonConstant::CHALLENGE_TYPE_HTTP => [ + 'abc.example.com' + ], + + CommonConstant::CHALLENGE_TYPE_DNS => [ + '*.www.example.com', + 'www.example.com', + ], +]; + +$client = new Client(['alert@example.com'], '../data/', TRUE); + +$order = $client->getOrder($domainInfo, CommonConstant::KEY_PAIR_TYPE_RSA); +// $order = $client->getOrder($domainInfo, CommonConstant::KEY_PAIR_TYPE_RSA, TRUE); // Renew certificates + +$challengeList = $order->getPendingChallengeList(); + +/* Verify authorizations */ +foreach ($challengeList as $challenge) +{ + $challengeType = $challenge->getType(); // http-01 or dns-01 + $credential = $challenge->getCredential(); + + // echo $challengeType."\n"; + // print_r($credential); + + /* http-01 */ + if ($challengeType == CommonConstant::CHALLENGE_TYPE_HTTP) + { + /* example purpose, create or update the ACME challenge file for this domain */ + setChallengeFile( + $credential['identifier'], + $credential['fileName'], + $credential['fileContent']); + } + + /* dns-01 */ + else if ($challengeType == CommonConstant::CHALLENGE_TYPE_DNS) + { + /* example purpose, create or update the ACME challenge DNS record for this domain */ + setDNSRecore( + $credential['identifier'], + $credential['dnsContent'] + ); + } + + /* Infinite loop until the authorization status becomes valid */ + $challenge->verify(); +} + +$certificateInfo = $order->getCertificateFile(); + +// print_r($certificateInfo); diff --git a/src/Client.php b/src/Client.php new file mode 100644 index 0000000..ab9407f --- /dev/null +++ b/src/Client.php @@ -0,0 +1,62 @@ + + * @link https://github.com/stonemax/acme2 + * @copyright Copyright © 2018 Zhang Jinlong + * @license https://opensource.org/licenses/mit-license.php MIT License + */ + +namespace stonemax\acme2; + +/** + * Class Client + * @package stonemax\acme2 + */ +class Client +{ + /** + * Runtime instance + * @var Runtime + */ + public static $runtime; + + /** + * Client constructor. + * @param array $emailList + * @param string $storagePath + * @param bool $staging + */ + public function __construct($emailList, $storagePath, $staging = FALSE) + { + self::$runtime = new Runtime($emailList, $storagePath, $staging); + + self::$runtime->init(); + } + + /** + * Get account service instance + * @return services\AccountService + */ + public function getAccount() + { + return self::$runtime->account; + } + + /** + * Get order service instance + * @param array $domainInfo + * @param string $algorithm + * @param bool $renew + * @return services\OrderService + * @throws exceptions\AccountException + * @throws exceptions\NonceException + * @throws exceptions\OrderException + * @throws exceptions\RequestException + */ + public function getOrder($domainInfo, $algorithm, $renew = FALSE) + { + return self::$runtime->getOrder($domainInfo, $algorithm, $renew); + } +} diff --git a/src/Runtime.php b/src/Runtime.php new file mode 100644 index 0000000..748ab6d --- /dev/null +++ b/src/Runtime.php @@ -0,0 +1,121 @@ + + * @link https://github.com/stonemax/acme2 + * @copyright Copyright © 2018 Zhang Jinlong + * @license https://opensource.org/licenses/mit-license.php MIT License + */ + +namespace stonemax\acme2; + +use stonemax\acme2\services\AccountService; +use stonemax\acme2\services\EndpointService; +use stonemax\acme2\services\NonceService; +use stonemax\acme2\services\OrderService; + +/** + * Class Runtime + * @package stonemax\acme2 + */ +class Runtime +{ + /** + * Email list + * @var array + */ + public $emailList; + + /** + * Storage path for certificate keys, public/private key pair and so on + * @var string + */ + public $storagePath; + + /** + * If staging status + * @var bool + */ + public $staging; + + /** + * Config params + * @var array + */ + public $params; + + /** + * Account service instance + * @var \stonemax\acme2\services\AccountService + */ + public $account; + + /** + * Order service instance + * @var \stonemax\acme2\services\OrderService + */ + public $order; + + /** + * Endpoint service instance + * @var \stonemax\acme2\services\EndpointService + */ + public $endpoint; + + /** + * Nonce service instance + * @var \stonemax\acme2\services\NonceService + */ + public $nonce; + + /** + * Runtime constructor. + * @param array $emailList + * @param string $storagePath + * @param bool $staging + */ + public function __construct($emailList, $storagePath, $staging = FALSE) + { + $this->emailList = array_filter(array_unique($emailList)); + $this->storagePath = rtrim(trim($storagePath), '/\\'); + $this->staging = boolval($staging); + + sort($this->emailList); + } + + /** + * Init + */ + public function init() + { + $this->params = require(__DIR__.'/config.php'); + + $this->endpoint = new EndpointService(); + $this->nonce = new NonceService(); + $this->account = new AccountService($this->storagePath.'/account'); + + $this->account->init(); + } + + /** + * Get order service instance + * @param array $domainInfo + * @param string $algorithm + * @param bool $renew + * @return OrderService + * @throws exceptions\AccountException + * @throws exceptions\NonceException + * @throws exceptions\OrderException + * @throws exceptions\RequestException + */ + public function getOrder($domainInfo, $algorithm, $renew) + { + if (!$this->order) + { + $this->order = new OrderService($domainInfo, $algorithm, $renew); + } + + return $this->order; + } +} diff --git a/src/config.php b/src/config.php new file mode 100644 index 0000000..224874e --- /dev/null +++ b/src/config.php @@ -0,0 +1,31 @@ + + * @link https://github.com/stonemax/acme2 + * @copyright Copyright © 2018 Zhang Jinlong + * @license https://opensource.org/licenses/mit-license.php MIT License + */ + +return [ + /** + * Software name + */ + 'software' => 'stonemax-acme2', + + /** + * Version number + */ + 'version' => '1.0', + + /** + * Endpoint production environment url + */ + 'endpointUrl' => 'https://acme-v02.api.letsencrypt.org/directory', + + /** + * Endpoint test environment url + */ + 'endpointStagingUrl' => 'https://acme-staging-v02.api.letsencrypt.org/directory', +]; diff --git a/src/constants/CommonConstant.php b/src/constants/CommonConstant.php new file mode 100644 index 0000000..26bbc43 --- /dev/null +++ b/src/constants/CommonConstant.php @@ -0,0 +1,42 @@ + + * @link https://github.com/stonemax/acme2 + * @copyright Copyright © 2018 Zhang Jinlong + * @license https://opensource.org/licenses/mit-license.php MIT License + */ + +namespace stonemax\acme2\constants; + +/** + * Class CommonConstant + * @package stonemax\acme2\constants + */ +class CommonConstant +{ + /** + * Key pair type: rsa + * @var int + */ + const KEY_PAIR_TYPE_RSA = 1; + + /** + * Key pair type: ec + * @var int + */ + const KEY_PAIR_TYPE_EC = 2; + + /** + * Challenge type: http-01 + * @var int + */ + const CHALLENGE_TYPE_HTTP = 'http-01'; + + /** + * Challenge type: dns-01 + * @var int + */ + const CHALLENGE_TYPE_DNS = 'dns-01'; +} diff --git a/src/exceptions/AccountException.php b/src/exceptions/AccountException.php new file mode 100644 index 0000000..51843a1 --- /dev/null +++ b/src/exceptions/AccountException.php @@ -0,0 +1,20 @@ + + * @link https://github.com/stonemax/acme2 + * @copyright Copyright © 2018 Zhang Jinlong + * @license https://opensource.org/licenses/mit-license.php MIT License + */ + +namespace stonemax\acme2\exceptions; + +/** + * Class AccountException + * @package stonemax\acme2\exceptions + */ +class AccountException extends \Exception +{ + +} diff --git a/src/exceptions/AuthorizationException.php b/src/exceptions/AuthorizationException.php new file mode 100644 index 0000000..6cd7308 --- /dev/null +++ b/src/exceptions/AuthorizationException.php @@ -0,0 +1,20 @@ + + * @link https://github.com/stonemax/acme2 + * @copyright Copyright © 2018 Zhang Jinlong + * @license https://opensource.org/licenses/mit-license.php MIT License + */ + +namespace stonemax\acme2\exceptions; + +/** + * Class AuthorizationException + * @package stonemax\acme2\exceptions + */ +class AuthorizationException extends \Exception +{ + +} diff --git a/src/exceptions/EndpointException.php b/src/exceptions/EndpointException.php new file mode 100644 index 0000000..f5d7140 --- /dev/null +++ b/src/exceptions/EndpointException.php @@ -0,0 +1,20 @@ + + * @link https://github.com/stonemax/acme2 + * @copyright Copyright © 2018 Zhang Jinlong + * @license https://opensource.org/licenses/mit-license.php MIT License + */ + +namespace stonemax\acme2\exceptions; + +/** + * Class EndpointException + * @package stonemax\acme2\exceptions + */ +class EndpointException extends \Exception +{ + +} diff --git a/src/exceptions/NonceException.php b/src/exceptions/NonceException.php new file mode 100644 index 0000000..fc962f1 --- /dev/null +++ b/src/exceptions/NonceException.php @@ -0,0 +1,20 @@ + + * @link https://github.com/stonemax/acme2 + * @copyright Copyright © 2018 Zhang Jinlong + * @license https://opensource.org/licenses/mit-license.php MIT License + */ + +namespace stonemax\acme2\exceptions; + +/** + * Class NonceException + * @package stonemax\acme2\exceptions + */ +class NonceException extends \Exception +{ + +} diff --git a/src/exceptions/OpenSSLException.php b/src/exceptions/OpenSSLException.php new file mode 100644 index 0000000..513d1fb --- /dev/null +++ b/src/exceptions/OpenSSLException.php @@ -0,0 +1,20 @@ + + * @link https://github.com/stonemax/acme2 + * @copyright Copyright © 2018 Zhang Jinlong + * @license https://opensource.org/licenses/mit-license.php MIT License + */ + +namespace stonemax\acme2\exceptions; + +/** + * Class OpenSSLException + * @package stonemax\acme2\exceptions + */ +class OpenSSLException extends \Exception +{ + +} diff --git a/src/exceptions/OrderException.php b/src/exceptions/OrderException.php new file mode 100644 index 0000000..b5c27f8 --- /dev/null +++ b/src/exceptions/OrderException.php @@ -0,0 +1,20 @@ + + * @link https://github.com/stonemax/acme2 + * @copyright Copyright © 2018 Zhang Jinlong + * @license https://opensource.org/licenses/mit-license.php MIT License + */ + +namespace stonemax\acme2\exceptions; + +/** + * Class OrderException + * @package stonemax\acme2\exceptions + */ +class OrderException extends \Exception +{ + +} diff --git a/src/exceptions/RequestException.php b/src/exceptions/RequestException.php new file mode 100644 index 0000000..cf958dd --- /dev/null +++ b/src/exceptions/RequestException.php @@ -0,0 +1,20 @@ + + * @link https://github.com/stonemax/acme2 + * @copyright Copyright © 2018 Zhang Jinlong + * @license https://opensource.org/licenses/mit-license.php MIT License + */ + +namespace stonemax\acme2\exceptions; + +/** + * Class RequestException + * @package stonemax\acme2\exceptions + */ +class RequestException extends \Exception +{ + +} diff --git a/src/exceptions/RuntimeException.php b/src/exceptions/RuntimeException.php new file mode 100644 index 0000000..b234f14 --- /dev/null +++ b/src/exceptions/RuntimeException.php @@ -0,0 +1,21 @@ + + * @link https://github.com/stonemax/acme2 + * @copyright Copyright © 2018 Zhang Jinlong + * @license https://opensource.org/licenses/mit-license.php MIT License + */ + + +namespace stonemax\acme2\exceptions; + +/** + * Class RuntimeException + * @package stonemax\acme2\exceptions + */ +class RuntimeException extends \Exception +{ + +} diff --git a/src/helpers/CommonHelper.php b/src/helpers/CommonHelper.php new file mode 100644 index 0000000..1d393da --- /dev/null +++ b/src/helpers/CommonHelper.php @@ -0,0 +1,210 @@ + + * @link https://github.com/stonemax/acme2 + * @copyright Copyright © 2018 Zhang Jinlong + * @license https://opensource.org/licenses/mit-license.php MIT License + */ + +namespace stonemax\acme2\helpers; + +use stonemax\acme2\exceptions\RequestException; + +/** + * Class CommonHelper + * @package stonemax\acme2\helpers + */ +class CommonHelper +{ + /** + * Base64 url safe encode + * @param string $string + * @return mixed + */ + public static function base64UrlSafeEncode($string) + { + return str_replace(['+', '/', '='], ['-', '_', ''], base64_encode($string)); + } + + /** + * Get replay nonce from http response header + * @param string $header + * @return bool|string + */ + public static function getNonceFromResponseHeader($header) + { + return self::getFieldFromHeader('Replay-Nonce', $header); + } + + /** + * Get location field from http response header + * @param string $header + * @return bool|string + */ + public static function getLocationFieldFromHeader($header) + { + return self::getFieldFromHeader('Location', $header); + } + + /** + * Get field from http response header + * @param string $field + * @param string $header + * @return bool|string + */ + public static function getFieldFromHeader($field, $header) + { + if (!preg_match("/{$field}:\s*(\S+)/i", $header, $matches)) + { + return FALSE; + } + + return trim($matches[1]); + } + + /** + * Check http challenge locally + * @param string $domain + * @param string $fileName + * @param string $fileContent + * @return bool + */ + public static function checkHttpChallenge($domain, $fileName, $fileContent) + { + $baseUrl = "{$domain}/.well-known/acme-challenge/{$fileName}"; + + foreach (['http', 'https'] as $schema) + { + $url = "{$schema}://$baseUrl"; + + try + { + list(, , $body) = RequestHelper::get($url); + } + catch (RequestException $e) + { + continue; + } + + if ($body == $fileContent) + { + return TRUE; + } + } + + return FALSE; + } + + /** + * Check dns challenge locally + * @param string $domain + * @param string $dnsContent + * @return bool + */ + public static function checkDNSChallenge($domain, $dnsContent) + { + $host = '_acme-challenge.'.str_replace('*.', '', $domain); + $recordList = @dns_get_record($host, DNS_TXT); + + if (is_array($recordList)) + { + foreach ($recordList as $record) + { + if ($record['host'] == $host && $record['type'] == 'TXT' && $record['txt'] == $dnsContent) + { + return TRUE; + } + } + } + + return FALSE; + } + + /** + * Get common name for csr generation + * @param array $domainList + * @return mixed + */ + public static function getCommonNameForCSR($domainList) + { + $domainLevel = []; + + foreach ($domainList as $domain) + { + $domainLevel[count(explode('.', $domain))][] = $domain; + } + + ksort($domainLevel); + + $shortestDomainList = reset($domainLevel); + + sort($shortestDomainList); + + return $shortestDomainList[0]; + } + + /** + * Get csr content without comment + * @param string $csr + * @return string + */ + public static function getCSRWithoutComment($csr) + { + $pattern = '/-----BEGIN\sCERTIFICATE\sREQUEST-----(.*)-----END\sCERTIFICATE\sREQUEST-----/is'; + + if (preg_match($pattern, $csr, $matches)) + { + return trim($matches[1]); + } + + return $csr; + } + + /** + * Get certificate content without comment + * @param string $certificate + * @return string + */ + public static function getCertificateWithoutComment($certificate) + { + $pattern = '/-----BEGIN\sCERTIFICATE-----(.*)-----END\sCERTIFICATE-----/is'; + + if (preg_match($pattern, $certificate, $matches)) + { + return trim($matches[1]); + } + + return $certificate; + } + + /** + * Extract certificate from server response + * @param string $certificateFromServer + * @return array|null + */ + public static function extractCertificate($certificateFromServer) + { + $certificate = ''; + $certificateFullChained = ''; + $pattern = '/-----BEGIN\sCERTIFICATE-----(.*?)-----END\sCERTIFICATE-----/is'; + + if (preg_match_all($pattern, $certificateFromServer, $matches)) + { + $certificate = trim($matches[0][0]); + + foreach ($matches[0] as $match) + { + $certificateFullChained .= trim($match)."\n"; + } + + return [ + 'certificate' => $certificate, + 'certificateFullChained' => trim($certificateFullChained), + ]; + } + + return NULL; + } +} diff --git a/src/helpers/OpenSSLHelper.php b/src/helpers/OpenSSLHelper.php new file mode 100644 index 0000000..49d89f9 --- /dev/null +++ b/src/helpers/OpenSSLHelper.php @@ -0,0 +1,238 @@ + + * @link https://github.com/stonemax/acme2 + * @copyright Copyright © 2018 Zhang Jinlong + * @license https://opensource.org/licenses/mit-license.php MIT License + */ + +namespace stonemax\acme2\helpers; + +use stonemax\acme2\Client; +use stonemax\acme2\constants\CommonConstant; +use stonemax\acme2\exceptions\OpenSSLException; + +/** + * Class OpenSSLHelper + * @package stonemax\acme2\helpers + */ +class OpenSSLHelper +{ + /** + * Genarate rsa public/private key pair + * @return array + * @throws OpenSSLException + */ + public static function generateRSAKeyPair() + { + return self::generateKeyPair(CommonConstant::KEY_PAIR_TYPE_RSA); + } + + /** + * Genarate ec public/private key pair + * @return array + * @throws OpenSSLException + */ + public static function generateECKeyPair() + { + return self::generateKeyPair(CommonConstant::KEY_PAIR_TYPE_EC); + } + + /** + * Generate private/public key pair + * @param $type + * @return array + * @throws OpenSSLException + */ + public static function generateKeyPair($type) + { + $configMap = [ + CommonConstant::KEY_PAIR_TYPE_RSA => [ + 'private_key_type' => OPENSSL_KEYTYPE_RSA, + 'private_key_bits' => 4096, + ], + + CommonConstant::KEY_PAIR_TYPE_EC => [ + 'private_key_type' => OPENSSL_KEYTYPE_EC, + 'curve_name' => 'prime256v1', + ], + ]; + + $typeNameMap = [ + CommonConstant::KEY_PAIR_TYPE_RSA => 'RSA', + CommonConstant::KEY_PAIR_TYPE_EC => 'EC', + ]; + + $resource = openssl_pkey_new($configMap[$type]); + + if ($resource === FALSE) + { + throw new OpenSSLException("Generate {$typeNameMap[$type]} key pair failed."); + } + + if (openssl_pkey_export($resource, $privateKey) === FALSE) + { + throw new OpenSSLException("Export {$typeNameMap[$type]} private key failed."); + } + + $detail = openssl_pkey_get_details($resource); + + if ($detail === FALSE) + { + throw new OpenSSLException("Get {$typeNameMap[$type]} key details failed."); + } + + openssl_pkey_free($resource); + + return [ + 'privateKey' => $privateKey, + 'publicKey' => $detail['key'], + ]; + } + + /** + * Generate CSR content + * @param array $domainList + * @param array $dn + * @param string $privateKey + * @return mixed + */ + public static function generateCSR($domainList, $dn, $privateKey) + { + $san = array_map( + function($domain) { + return "DNS:{$domain}"; + }, + $domainList + ); + + $opensslConfigFileResource = tmpfile(); + $opensslConfigFileMeta = stream_get_meta_data($opensslConfigFileResource); + $opensslConfigFilePath = $opensslConfigFileMeta['uri']; + + $content = " + HOME = . + RANDFILE = \$ENV::HOME/.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 = ".implode(',', $san)." + keyUsage = nonRepudiation, digitalSignature, keyEncipherment + "; + + fwrite($opensslConfigFileResource, $content); + + $privateKey = openssl_pkey_get_private($privateKey); + + $csr = openssl_csr_new( + $dn, + $privateKey, + [ + 'config' => $opensslConfigFilePath, + 'digest_alg' => 'sha256', + ] + ); + + openssl_csr_export($csr, $csr); + + return $csr; + } + + /** + * Generate thumbprint + * @param string|null $privateKey + * @return mixed + */ + public static function generateThumbprint($privateKey = NULL) + { + $privateKey = openssl_pkey_get_private($privateKey ?: Client::$runtime->account->getPrivateKey()); + $detail = openssl_pkey_get_details($privateKey); + + $accountKey = [ + 'e' => CommonHelper::base64UrlSafeEncode($detail['rsa']['e']), + 'kty' => 'RSA', + 'n' => CommonHelper::base64UrlSafeEncode($detail['rsa']['n']), + ]; + + return CommonHelper::base64UrlSafeEncode(hash('sha256', json_encode($accountKey), TRUE)); + } + + /** + * Generate JWS(Json Web Signature) with field `jwk` + * @param string $url + * @param array|string $payload + * @param string|null $privateKey + * @return string + * @throws \stonemax\acme2\exceptions\NonceException + * @throws \stonemax\acme2\exceptions\RequestException + */ + public static function generateJWSOfJWK($url, $payload, $privateKey = NULL) + { + $privateKey = openssl_pkey_get_private($privateKey ?: Client::$runtime->account->getPrivateKey()); + $detail = openssl_pkey_get_details($privateKey); + + $protected = [ + 'alg' => 'RS256', + 'jwk' => [ + 'kty' => 'RSA', + 'n' => CommonHelper::base64UrlSafeEncode($detail['rsa']['n']), + 'e' => CommonHelper::base64UrlSafeEncode($detail['rsa']['e']), + ], + 'nonce' => Client::$runtime->nonce->get(), + 'url' => $url, + ]; + + $protectedBase64 = CommonHelper::base64UrlSafeEncode(json_encode($protected)); + $payloadBase64 = CommonHelper::base64UrlSafeEncode(is_array($payload) ? json_encode($payload) : $payload); + + openssl_sign($protectedBase64.'.'.$payloadBase64, $signature, $privateKey, 'SHA256'); + $signatureBase64 = CommonHelper::base64UrlSafeEncode($signature); + + return json_encode([ + 'protected' => $protectedBase64, + 'payload' => $payloadBase64, + 'signature' => $signatureBase64, + ]); + } + + /** + * Generate JWS(Json Web Signature) with field `kid` + * @param string $url + * @param string $kid + * @param array|string $payload + * @return string + * @throws \stonemax\acme2\exceptions\NonceException + * @throws \stonemax\acme2\exceptions\RequestException + */ + public static function generateJWSOfKid($url, $kid, $payload) + { + $privateKey = openssl_pkey_get_private(Client::$runtime->account->getPrivateKey()); + + $protected = [ + 'alg' => 'RS256', + 'kid' => $kid, + 'nonce' => Client::$runtime->nonce->get(), + 'url' => $url, + ]; + + $protectedBase64 = CommonHelper::base64UrlSafeEncode(json_encode($protected)); + $payloadBase64 = CommonHelper::base64UrlSafeEncode(is_array($payload) ? json_encode($payload) : $payload); + + openssl_sign($protectedBase64.'.'.$payloadBase64, $signature, $privateKey, 'SHA256'); + $signatureBase64 = CommonHelper::base64UrlSafeEncode($signature); + + return json_encode([ + 'protected' => $protectedBase64, + 'payload' => $payloadBase64, + 'signature' => $signatureBase64, + ]); + } +} diff --git a/src/helpers/RequestHelper.php b/src/helpers/RequestHelper.php new file mode 100644 index 0000000..56215e7 --- /dev/null +++ b/src/helpers/RequestHelper.php @@ -0,0 +1,177 @@ + + * @link https://github.com/stonemax/acme2 + * @copyright Copyright © 2018 Zhang Jinlong + * @license https://opensource.org/licenses/mit-license.php MIT License + */ + +namespace stonemax\acme2\helpers; + +use stonemax\acme2\Client; +use stonemax\acme2\exceptions\RequestException; + +/** + * Class RequestHelper + * @package stonemax\acme2\helpers + */ +class RequestHelper +{ + /** + * Make http GET request + * @param string $url + * @return array + * @throws RequestException + */ + public static function get($url) + { + $crlf = "\r\n"; + $urlMap = self::parseUrl($url); + + $requestData = [ + "GET {$urlMap['path']}{$urlMap['query']} HTTP/1.1", + "Host: {$urlMap['host']}", + "Accept: application/json", + "User-Agent: ".Client::$runtime->params['software'].'/'.Client::$runtime->params['version'], + "Connection: close{$crlf}{$crlf}", + ]; + + return self::run( + $urlMap['hostWithSchema'], + $urlMap['port'], + implode($crlf, $requestData) + ); + } + + /** + * Make http POST request + * @param string $url + * @param string $data + * @return array + * @throws RequestException + */ + public static function post($url, $data) + { + $crlf = "\r\n"; + $urlMap = self::parseUrl($url); + + $requestData = [ + "POST {$urlMap['path']}{$urlMap['query']} HTTP/1.1", + "Host: {$urlMap['host']}", + "Accept: application/json", + "User-Agent: ".Client::$runtime->params['software'].'/'.Client::$runtime->params['version'], + "Connection: close", + "Content-Type: application/json", + "Content-Length: ".strlen($data).$crlf, + $data + ]; + + return self::run( + $urlMap['hostWithSchema'], + $urlMap['port'], + implode($crlf, $requestData) + ); + } + + /** + * Make http HEAD request + * @param string $url + * @return array + * @throws RequestException + */ + public static function head($url) + { + $crlf = "\r\n"; + $urlMap = self::parseUrl($url); + + $requestData = [ + "HEAD {$urlMap['path']}{$urlMap['query']} HTTP/1.1", + "Host: {$urlMap['host']}", + "Accept: application/json", + "User-Agent: ".Client::$runtime->params['software'].'/'.Client::$runtime->params['version'], + "Connection: close{$crlf}{$crlf}", + ]; + + return self::run( + $urlMap['hostWithSchema'], + $urlMap['port'], + implode($crlf, $requestData) + ); + } + + /** + * Parse url + * @param string $url + * @return array + */ + public static function parseUrl($url) + { + $tmp = parse_url($url); + + $hostWithSchema = $tmp['scheme'] == 'https' + ? "ssl://{$tmp['host']}" + : "tcp://{$tmp['host']}"; + + $port = isset($tmp['port']) + ? intval($tmp['port']) + : ($tmp['scheme'] == 'https' ? 443 : 80); + + return [ + 'hostWithSchema' => $hostWithSchema, + 'host' => $tmp['host'], + 'port' => $port, + 'path' => isset($tmp['path']) ? $tmp['path'] : '/', + 'query' => isset($tmp['query']) ? '?'.$tmp['query'] : '', + ]; + } + + /** + * Make http request + * @param string $hostWithSchema + * @param integer $port + * @param string $requestData + * @return array + * @throws RequestException + */ + public static function run($hostWithSchema, $port, $requestData) + { + $crlf = "\r\n"; + $response = ''; + $handler = @fsockopen($hostWithSchema, $port, $errorNumber, $errorString, 30); + + if (!$handler) + { + throw new RequestException("Open http sock open failed, the error number is: {$errorNumber}, the error message is: {$errorString}"); + } + + fwrite($handler, $requestData); + + while (!feof($handler)) + { + $response .= fread($handler, 128); + } + + fclose($handler); + + list($header, $body) = explode($crlf.$crlf, $response, 2); + + /* Get replay nonce from this request's header */ + if ($nonce = CommonHelper::getNonceFromResponseHeader($header)) + { + Client::$runtime->nonce->set($nonce); + } + + preg_match('/\d{3}/', trim($header), $matches); + + $body = trim($body); + $bodyDecoded = json_decode(trim($body), TRUE); + + return [ + intval($matches[0]), // response http status code + $header, // response http header + $bodyDecoded !== NULL ? $bodyDecoded : $body, // response data + ]; + } +} diff --git a/src/services/AccountService.php b/src/services/AccountService.php new file mode 100644 index 0000000..9576558 --- /dev/null +++ b/src/services/AccountService.php @@ -0,0 +1,393 @@ + + * @link https://github.com/stonemax/acme2 + * @copyright Copyright © 2018 Zhang Jinlong + * @license https://opensource.org/licenses/mit-license.php MIT License + */ + +namespace stonemax\acme2\services; + +use stonemax\acme2\Client; +use stonemax\acme2\exceptions\AccountException; +use stonemax\acme2\helpers\CommonHelper; +use stonemax\acme2\helpers\OpenSSLHelper; +use stonemax\acme2\helpers\RequestHelper; + +/** + * Class AccountService + * @package stonemax\acme2\services + */ +class AccountService +{ + /** + * Account id + * @var string + */ + public $id; + + /** + * Account key + * @var array + */ + public $key; + + /** + * Account contact list + * @var array + */ + public $contact; + + /** + * Account agreement file url + * @var string + */ + public $agreement; + + /** + * Account initial ip + * @var string + */ + public $initialIp; + + /** + * Account creation time + * @var string + */ + public $createdAt; + + /** + * Account status + * @var string + */ + public $status; + + /** + * Access account info url + * @var string + */ + public $accountUrl; + + /** + * Private key storate path + * @var string + */ + private $_privateKeyPath; + + /** + * Public key storage path + * @var string + */ + private $_publicKeyPath; + + /** + * AccountService constructor. + * @param $accountStoragePath + * @throws AccountException + */ + public function __construct($accountStoragePath) + { + if ( + !is_dir($accountStoragePath) + && mkdir($accountStoragePath, 0755, TRUE) === FALSE + ) + { + throw new AccountException("create directory({$accountStoragePath}) failed, please check the permission."); + } + + $this->_privateKeyPath = $accountStoragePath.'/private.pem'; + $this->_publicKeyPath = $accountStoragePath.'/public.pem'; + } + + /** + * Init + * @throws AccountException + * @throws \stonemax\acme2\exceptions\NonceException + * @throws \stonemax\acme2\exceptions\OpenSSLException + * @throws \stonemax\acme2\exceptions\RequestException + */ + public function init() + { + if (is_file($this->_publicKeyPath) && is_file($this->_privateKeyPath)) + { + $this->getAccount(); + + return; + } + + @unlink($this->_privateKeyPath); + @unlink($this->_publicKeyPath); + + $this->createAccount(); + } + + /** + * Create new account + * @return array + * @throws AccountException + * @throws \stonemax\acme2\exceptions\NonceException + * @throws \stonemax\acme2\exceptions\OpenSSLException + * @throws \stonemax\acme2\exceptions\RequestException + */ + private function createAccount() + { + $this->createKeyPairFile(); + + $contactList = array_map( + function($email) { + return "mailto:{$email}"; + }, + Client::$runtime->emailList + ); + + $payload = [ + 'contact' => $contactList, + 'termsOfServiceAgreed' => TRUE, + ]; + + $jws = OpenSSLHelper::generateJWSOfJWK( + Client::$runtime->endpoint->newAccount, + $payload + ); + + list($code, $header, $body) = RequestHelper::post(Client::$runtime->endpoint->newAccount, $jws); + + if ($code != 201) + { + throw new AccountException("Create account failed, the code is: {$code}, the header is: {$header}, the body is: ".print_r($body, TRUE)); + } + + if (!($accountUrl = CommonHelper::getLocationFieldFromHeader($header))) + { + throw new AccountException("Parse account url failed, the header is: {$header}"); + } + + $accountInfo = array_merge($body, ['accountUrl' => $accountUrl]); + + $this->populate($accountInfo); + + return $accountInfo; + } + + /** + * Get account info + * @return array + * @throws AccountException + * @throws \stonemax\acme2\exceptions\NonceException + * @throws \stonemax\acme2\exceptions\RequestException + */ + private function getAccount() + { + $accountUrl = $this->getAccountUrl(); + + $jws = OpenSSLHelper::generateJWSOfKid( + $accountUrl, + $accountUrl, + ['' => ''] + ); + + list($code, $header, $body) = RequestHelper::post($accountUrl, $jws); + + if ($code != 200) + { + throw new AccountException("Get account info failed, the code is: {$code}, the header is: {$header}, the body is: ".print_r($body, TRUE)); + } + + $this->populate($body); + + return array_merge($body, ['accountUrl' => $accountUrl]); + } + + /** + * Get account url + * @return string + * @throws AccountException + * @throws \stonemax\acme2\exceptions\NonceException + * @throws \stonemax\acme2\exceptions\RequestException + */ + public function getAccountUrl() + { + if ($this->accountUrl) + { + return $this->accountUrl; + } + + $jws = OpenSSLHelper::generateJWSOfJWK( + Client::$runtime->endpoint->newAccount, + ['onlyReturnExisting' => TRUE] + ); + + list($code, $header, $body) = RequestHelper::post(Client::$runtime->endpoint->newAccount, $jws); + + if ($code != 200) + { + throw new AccountException("Get account url failed, the code is: {$code}, the header is: {$header}, the body is: ".print_r($body, TRUE)); + } + + if (!($accountUrl = CommonHelper::getLocationFieldFromHeader($header))) + { + throw new AccountException("Parse account url failed, the header is: {$header}"); + } + + $this->accountUrl = $accountUrl; + + return $this->accountUrl; + } + + /** + * Update account contact info + * @param $emailList + * @return array + * @throws AccountException + * @throws \stonemax\acme2\exceptions\NonceException + * @throws \stonemax\acme2\exceptions\RequestException + */ + public function updateAccountContact($emailList) + { + $accountUrl = $this->getAccountUrl(); + + $contactList = array_map( + function($email) { + return "mailto:{$email}"; + }, + $emailList + ); + + $jws = OpenSSLHelper::generateJWSOfKid( + $accountUrl, + $accountUrl, + ['contact' => $contactList] + ); + + list($code, $header, $body) = RequestHelper::post($accountUrl, $jws); + + if ($code != 200) + { + throw new AccountException("Update account contact info failed, the code is: {$code}, the header is: {$header}, the body is: ".print_r($body, TRUE)); + } + + $this->populate($body); + + return array_merge($body, ['accountUrl' => $accountUrl]); + } + + /** + * Update accout private/public keys + * @throws AccountException + * @throws \stonemax\acme2\exceptions\NonceException + * @throws \stonemax\acme2\exceptions\OpenSSLException + * @throws \stonemax\acme2\exceptions\RequestException + */ + public function updateAccountKey() + { + $keyPair = OpenSSLHelper::generateRSAKeyPair(); + + $privateKey = openssl_pkey_get_private($keyPair['privateKey']); + $detail = openssl_pkey_get_details($privateKey); + + $innerPayload = [ + 'account' => $this->getAccountUrl(), + 'newKey' => [ + 'kty' => 'RSA', + 'n' => CommonHelper::base64UrlSafeEncode($detail['rsa']['n']), + 'e' => CommonHelper::base64UrlSafeEncode($detail['rsa']['e']), + ], + ]; + + $outerPayload = OpenSSLHelper::generateJWSOfJWK( + Client::$runtime->endpoint->keyChange, + $innerPayload, + $keyPair['privateKey'] + ); + + $jws = OpenSSLHelper::generateJWSOfKid( + Client::$runtime->endpoint->keyChange, + $this->getAccountUrl(), + $outerPayload + ); + + list($code, $header, $body) = RequestHelper::post(Client::$runtime->endpoint->keyChange, $jws); + + if ($code != 200) + { + throw new AccountException("Update account key failed, the code is: {$code}, the header is: {$header}, the body is: ".print_r($body, TRUE)); + } + + $this->populate($body); + $this->createKeyPairFile($keyPair); + + return array_merge($body, ['accountUrl' => $this->getAccountUrl()]); + } + + /** + * Deactivate account + * @return array + * @throws AccountException + * @throws \stonemax\acme2\exceptions\NonceException + * @throws \stonemax\acme2\exceptions\RequestException + */ + public function deactivateAccount() + { + $jws = OpenSSLHelper::generateJWSOfKid( + $this->getAccountUrl(), + $this->getAccountUrl(), + ['status' => 'deactivated'] + ); + + list($code, $header, $body) = RequestHelper::post($this->getAccountUrl(), $jws); + + if ($code != 200) + { + throw new AccountException("Deactivate account failed, the code is: {$code}, the header is: {$header}, the body is: ".print_r($body, TRUE)); + } + + $this->populate($body); + + @unlink($this->_privateKeyPath); + @unlink($this->_publicKeyPath); + + return array_merge($body, ['accountUrl' => $this->getAccountUrl()]); + } + + /** + * Get private key content + * @return bool|string + */ + public function getPrivateKey() + { + return file_get_contents($this->_privateKeyPath); + } + + /** + * Create private/public key pair files + * @param array|null $keyPair + * @throws AccountException + * @throws \stonemax\acme2\exceptions\OpenSSLException + */ + private function createKeyPairFile($keyPair = NULL) + { + $keyPair = $keyPair ?: OpenSSLHelper::generateRSAKeyPair(); + + $result = file_put_contents($this->_privateKeyPath, $keyPair['privateKey']) + && file_put_contents($this->_publicKeyPath, $keyPair['publicKey']); + + if ($result === FALSE) + { + throw new AccountException("Create account key pair files failed, the private key path is: {$this->_privateKeyPath}, the public key path is: {$this->_publicKeyPath}"); + } + } + + /** + * Populate properties of instance + * @param array $accountInfo + */ + private function populate($accountInfo) + { + foreach ($accountInfo as $key => $value) + { + $this->{$key} = $value; + } + } +} diff --git a/src/services/AuthorizationService.php b/src/services/AuthorizationService.php new file mode 100644 index 0000000..33889d4 --- /dev/null +++ b/src/services/AuthorizationService.php @@ -0,0 +1,217 @@ + + * @link https://github.com/stonemax/acme2 + * @copyright Copyright © 2018 Zhang Jinlong + * @license https://opensource.org/licenses/mit-license.php MIT License + */ + +namespace stonemax\acme2\services; + +use stonemax\acme2\Client; +use stonemax\acme2\constants\CommonConstant; +use stonemax\acme2\exceptions\AuthorizationException; +use stonemax\acme2\helpers\CommonHelper; +use stonemax\acme2\helpers\OpenSSLHelper; +use stonemax\acme2\helpers\RequestHelper; + +/** + * Class AuthorizationService + * @package stonemax\acme2\services + */ +class AuthorizationService +{ + /** + * Domain info + * @var array + */ + public $identifier; + + /** + * Authorization status: pending, valid, invalid + * @var string + */ + public $status; + + /** + * Expire time, like yyyy-mm-ddThh:mm:ssZ + * @var string + */ + public $expires; + + /** + * Supplied challenge types + * @var array + */ + public $challenges; + + /** + * Wildcard domain or not + * @var bool + */ + public $wildcard = FALSE; + + /** + * Initial domain name + * @var string + */ + public $domain; + + /** + * Access this url to get authorization info + * @var string + */ + public $authorizationUrl; + + /** + * AuthorizationService constructor. + * @param string $authorizationUrl + * @throws AuthorizationException + * @throws \stonemax\acme2\exceptions\RequestException + */ + public function __construct($authorizationUrl) + { + $this->authorizationUrl = $authorizationUrl; + + $this->getAuthorization(); + } + + /** + * Get authorization info + * @return array + * @throws AuthorizationException + * @throws \stonemax\acme2\exceptions\RequestException + */ + public function getAuthorization() + { + list($code, $header , $body) = RequestHelper::get($this->authorizationUrl); + + if ($code != 200) + { + throw new AuthorizationException("Get authorization info failed, the authorization url is: {$this->authorizationUrl}, the code is: {$code}, the header is: {$header}, the body is: ".print_r($body, TRUE)); + } + + $this->populate($body); + + return array_merge($body, ['authorizationUrl' => $this->authorizationUrl]); + } + + /** + * Get challenge to verify + * @param string $type http-01 or dns-01 + * @return mixed|null + */ + public function getChallenge($type) + { + foreach ($this->challenges as $challenge) + { + if ($challenge['type'] == $type) + { + return $challenge; + } + } + + return NULL; + } + + /** + * Make letsencrypt to verify + * @param string $type + * @return bool + * @throws AuthorizationException + * @throws \stonemax\acme2\exceptions\AccountException + * @throws \stonemax\acme2\exceptions\NonceException + * @throws \stonemax\acme2\exceptions\RequestException + */ + public function verify($type) + { + $challenge = $this->getChallenge($type); + + if ($this->status != 'pending' || $challenge['status'] != 'pending') + { + return TRUE; + } + + $keyAuthorization = $challenge['token'].'.'.OpenSSLHelper::generateThumbprint(); + + while (!$this->verifyLocally($type, $keyAuthorization)) + { + sleep(3); + } + + $jwk = OpenSSLHelper::generateJWSOfKid( + $challenge['url'], + Client::$runtime->account->getAccountUrl(), + ['keyAuthorization' => $keyAuthorization] + ); + + list($code, $header, $body) = RequestHelper::post($challenge['url'], $jwk); + + if ($code != 200) + { + throw new AuthorizationException("Send Request to letsencrypt to verify authorization failed, the url is: {$challenge['url']}, the domain is: {$this->identifier['value']}, the code is: {$code}, the header is: {$header}, the body is: ".print_r($body, TRUE)); + } + + while ($this->status == 'pending') + { + sleep(3); + + $this->getAuthorization(); + } + + if ($this->status == 'invalid') + { + throw new AuthorizationException("Verify {$this->domain} failed, the authorization status becomes invalid."); + } + + return TRUE; + } + + /** + * Check locally + * @param string $type + * @param string $keyAuthorization + * @return bool + * @throws \stonemax\acme2\exceptions\RequestException + */ + private function verifyLocally($type, $keyAuthorization) + { + $challenge = $this->getChallenge($type); + $domain = $this->identifier['value']; + + if ($type == CommonConstant::CHALLENGE_TYPE_HTTP) + { + if (!CommonHelper::checkHttpChallenge($domain, $challenge['token'], $keyAuthorization)) + { + return FALSE; + } + } + else + { + $dnsContent = CommonHelper::base64UrlSafeEncode(hash('sha256', $keyAuthorization, TRUE)); + + if (!CommonHelper::checkDNSChallenge($domain, $dnsContent)) + { + return FALSE; + } + } + + return TRUE; + } + + /** + * Populate properties of this instance + * @param array $authorizationInfo + */ + private function populate($authorizationInfo) + { + foreach ($authorizationInfo as $key => $value) + { + $this->{$key} = $value; + } + + $this->domain = ($this->wildcard ? '*.' : '').$this->identifier['value']; + } +} diff --git a/src/services/ChallengeService.php b/src/services/ChallengeService.php new file mode 100644 index 0000000..3767a8f --- /dev/null +++ b/src/services/ChallengeService.php @@ -0,0 +1,96 @@ + + * @link https://github.com/stonemax/acme2 + * @copyright Copyright © 2018 Zhang Jinlong + * @license https://opensource.org/licenses/mit-license.php MIT License + */ + +namespace stonemax\acme2\services; + +use stonemax\acme2\Client; + +/** + * Class ChallengeService + * @package stonemax\acme2\services + */ +class ChallengeService +{ + /** + * Challenge type: http-01, dns-01 + * @var string + */ + private $_type; + + /** + * challenge Credential + * @var array + */ + private $_credential; + + /** + * Authorization inntance + * @var \stonemax\acme2\services\AuthorizationService + */ + private $_authorication; + + /** + * ChallengeService constructor. + * @param string $type + * @param \stonemax\acme2\services\AuthorizationService $authorization + */ + public function __construct($type, $authorization) + { + $this->_type = $type; + $this->_authorication = $authorization; + } + + /** + * Get challenge type + * @return string + */ + public function getType() + { + return $this->_type; + } + + /** + * Get challenge credential + * @return array + */ + public function getCredential() + { + return $this->_credential; + } + + /** + * Set challenge credential + * @param array $credential + */ + public function setCredential($credential) + { + $this->_credential = $credential; + } + + /** + * Verify + * @return bool + * @throws \stonemax\acme2\exceptions\AccountException + * @throws \stonemax\acme2\exceptions\AuthorizationException + * @throws \stonemax\acme2\exceptions\NonceException + * @throws \stonemax\acme2\exceptions\RequestException + */ + public function verify() + { + $orderService = Client::$runtime->order; + + if ($orderService->isOrderFinalized() || $orderService->isAllAuthorizationValid() === TRUE) + { + return TRUE; + } + + return $this->_authorication->verify($this->_type); + } +} diff --git a/src/services/EndpointService.php b/src/services/EndpointService.php new file mode 100644 index 0000000..762b50a --- /dev/null +++ b/src/services/EndpointService.php @@ -0,0 +1,81 @@ + + * @link https://github.com/stonemax/acme2 + * @copyright Copyright © 2018 Zhang Jinlong + * @license https://opensource.org/licenses/mit-license.php MIT License + */ + +namespace stonemax\acme2\services; + +use stonemax\acme2\Client; +use stonemax\acme2\exceptions\EndpointException; +use stonemax\acme2\helpers\RequestHelper; + +/** + * Class EndpointService + * @package stonemax\acme2\services + */ +class EndpointService +{ + /** Change account key url + * @var string + */ + public $keyChange; + + /** Create new account url + * @var string + */ + public $newAccount; + + /** Generate new nonce url + * @var string + */ + public $newNonce; + + /** Create new order url + * @var string + */ + public $newOrder; + + /** Revoke certificate url + * @var string + */ + public $revokeCert; + + /** + * EndpointService constructor. + * @throws EndpointException + * @throws \stonemax\acme2\exceptions\RequestException + */ + public function __construct() + { + $this->populate(); + } + + /** + * Populate endpoint info + * @throws EndpointException + * @throws \stonemax\acme2\exceptions\RequestException + */ + private function populate() + { + $endpointUrl = Client::$runtime->staging === FALSE + ? Client::$runtime->params['endpointUrl'] + : Client::$runtime->params['endpointStagingUrl']; + + list($code, , $data) = RequestHelper::get($endpointUrl); + + if ($code != 200) + { + throw new EndpointException("Get endpoint info failed, the url is: {$endpointUrl}"); + } + + foreach ($data as $key => $value) + { + $this->{$key} = $value; + } + } +} diff --git a/src/services/NonceService.php b/src/services/NonceService.php new file mode 100644 index 0000000..8b87066 --- /dev/null +++ b/src/services/NonceService.php @@ -0,0 +1,101 @@ + + * @link https://github.com/stonemax/acme2 + * @copyright Copyright © 2018 Zhang Jinlong + * @license https://opensource.org/licenses/mit-license.php MIT License + */ + +namespace stonemax\acme2\services; + +use stonemax\acme2\Client; +use stonemax\acme2\exceptions\NonceException; +use stonemax\acme2\helpers\CommonHelper; +use stonemax\acme2\helpers\RequestHelper; + +/** + * Class NonceService + * @package stonemax\acme2\services + */ +class NonceService +{ + /** + * Request nonce anti replay attack + * @var string + */ + private $_nonce; + + /** + * NonceService constructor. + */ + public function __construct() + { + // do nothing + } + + /** + * Get nonce + * @return string + * @throws NonceException + * @throws \stonemax\acme2\exceptions\RequestException + */ + public function get() + { + if (!$this->_nonce) + { + $this->_nonce = $this->getNew(); + } + + $nonce = $this->_nonce; + + $this->destroy(); + + return $nonce; + } + + /** + * Set nonce + * @param string $nonce + */ + public function set($nonce) + { + $this->_nonce = $nonce; + } + + /** + * Destroy nonce + */ + public function destroy() + { + $this->_nonce = NULL; + } + + /** + * Get new nonce for next request + * @return string + * @throws NonceException + * @throws \stonemax\acme2\exceptions\RequestException + */ + private function getNew() + { + $newNonceUrl = Client::$runtime->endpoint->newNonce; + + list($code, $header, ) = RequestHelper::head($newNonceUrl); + + if ($code != 204) + { + throw new NonceException("Get new nonce failed, the url is: {$newNonceUrl}"); + } + + $nonce = CommonHelper::getNonceFromResponseHeader($header); + + if (!$nonce) + { + throw new NonceException("Get new nonce failed, the header doesn't contain `Replay-Nonce` filed, the url is: {$newNonceUrl}"); + } + + return $nonce; + } +} diff --git a/src/services/OrderService.php b/src/services/OrderService.php new file mode 100644 index 0000000..c6d4a6f --- /dev/null +++ b/src/services/OrderService.php @@ -0,0 +1,644 @@ + + * @link https://github.com/stonemax/acme2 + * @copyright Copyright © 2018 Zhang Jinlong + * @license https://opensource.org/licenses/mit-license.php MIT License + */ + +namespace stonemax\acme2\services; + +use stonemax\acme2\Client; +use stonemax\acme2\constants\CommonConstant; +use stonemax\acme2\exceptions\OrderException; +use stonemax\acme2\helpers\CommonHelper; +use stonemax\acme2\helpers\OpenSSLHelper; +use stonemax\acme2\helpers\RequestHelper; + +/** + * Class OrderService + * @package stonemax\acme2\services + */ +class OrderService +{ + /** + * Order status: pending, processing, valid, invalid + * @var string + */ + public $status; + + /** + * Order expire time + * @var string + */ + public $expires; + + /** + * Domains info + * @var array + */ + public $identifiers; + + /** + * Domain authorization info + * @var array + */ + public $authorizations; + + /** + * Finalize order url + * @var string + */ + public $finalize; + + /** + * Fetch certificate content url + * @var string + */ + public $certificate; + + /** + * Order info url + * @var string + */ + public $orderUrl; + + /** + * Order AuthorizationService instance list + * @var AuthorizationService[] + */ + private $_authorizationList; + + /** + * Domain list + * @var array + */ + private $_domainList; + + /** + * Domain challenge type info + * @var array + */ + private $_domainChallengeTypeMap; + + /** + * Certificate encrypt type + * @var int + */ + private $_algorithm; + + /** + * Is a new order + * @var bool + */ + private $_renew; + + /** + * Certificate private key file path + * @var string + */ + private $_privateKeyPath; + + /** + * Certificate public key file path + * @var string + */ + private $_publicKeyPath; + + /** + * Certificate csr file storage path + * @var string + */ + private $_csrPath; + + /** + * Certificate storage file path + * @var string + */ + private $_certificatePath; + + /** + * Certificate full-chained file storage path + * @var string + */ + private $_certificateFullChainedPath; + + /** + * Order info file storage path + * @var string + */ + private $_orderInfoPath; + + /** + * OrderService constructor. + * @param array $domainInfo + * @param string $algorithm + * @param bool $renew + * @throws OrderException + * @throws \stonemax\acme2\exceptions\AccountException + * @throws \stonemax\acme2\exceptions\NonceException + * @throws \stonemax\acme2\exceptions\RequestException + */ + public function __construct($domainInfo, $algorithm, $renew = FALSE) + { + $this->_algorithm = $algorithm; + $this->_renew = boolval($renew); + + if ($this->_algorithm == CommonConstant::KEY_PAIR_TYPE_EC && version_compare(PHP_VERSION, '7.1.0') == -1) + { + throw new OrderException("PHP version 7.1 or higher required for generating EC certificates."); + } + + foreach ($domainInfo as $challengeType => $domainList) + { + foreach ($domainList as $domain) + { + $domain = trim($domain); + + $this->_domainList[] = $domain; + $this->_domainChallengeTypeMap[$domain] = $challengeType; + } + } + + $this->_domainList = array_unique($this->_domainList); + + sort($this->_domainList); + + $this->init(); + } + + /** + * Initialization + * @throws OrderException + * @throws \stonemax\acme2\exceptions\AccountException + * @throws \stonemax\acme2\exceptions\NonceException + * @throws \stonemax\acme2\exceptions\RequestException + */ + public function init() + { + $flag = substr(md5(implode(',', $this->_domainList)), 11, 8); + + $algorithmNameMap = [ + CommonConstant::KEY_PAIR_TYPE_RSA => 'rsa', + CommonConstant::KEY_PAIR_TYPE_EC => 'ec', + ]; + + $algorithmName = $algorithmNameMap[$this->_algorithm]; + $basePath = Client::$runtime->storagePath.DIRECTORY_SEPARATOR.$flag.DIRECTORY_SEPARATOR.$algorithmName; + + if (!is_dir($basePath)) + { + mkdir($basePath, 0755, TRUE); + } + + $pathMap = [ + '_privateKeyPath' => 'private.pem', + '_publicKeyPath' => 'public.pem', + '_csrPath' => 'certificate.csr', + '_certificatePath' => 'certificate.crt', + '_certificateFullChainedPath' => 'certificate-fullchained.crt', + '_orderInfoPath' => 'ORDER', + ]; + + foreach ($pathMap as $propertyName => $fileName) + { + $this->{$propertyName} = $basePath.DIRECTORY_SEPARATOR.$fileName; + } + + if ($this->_renew) + { + foreach ($pathMap as $propertyName => $fileName) + { + @unlink($basePath.DIRECTORY_SEPARATOR.$fileName); + } + } + + is_file($this->_orderInfoPath) ? $this->getOrder() : $this->createOrder(); + + file_put_contents( + Client::$runtime->storagePath.DIRECTORY_SEPARATOR.$flag.DIRECTORY_SEPARATOR.'DOMAIN', + implode("\r\n", $this->_domainList) + ); + } + + /** + * Create new order + * @return array + * @throws OrderException + * @throws \stonemax\acme2\exceptions\AccountException + * @throws \stonemax\acme2\exceptions\NonceException + * @throws \stonemax\acme2\exceptions\RequestException + */ + private function createOrder() + { + $identifierList = []; + + foreach ($this->_domainList as $domain) + { + $identifierList[] = [ + 'type' => 'dns', + 'value' => $domain, + ]; + } + + $payload = [ + 'identifiers' => $identifierList, + 'notBefore' => '', + 'notAfter' => '', + ]; + + $jws = OpenSSLHelper::generateJWSOfKid( + Client::$runtime->endpoint->newOrder, + Client::$runtime->account->getAccountUrl(), + $payload + ); + + list($code, $header, $body) = RequestHelper::post(Client::$runtime->endpoint->newOrder, $jws); + + if ($code != 201) + { + throw new OrderException('Create order failed, the domain list is: '.implode(', ', $this->_domainList).", the code is: {$code}, the header is: {$header}, the body is: ".print_r($body, TRUE)); + } + + if (($orderUrl = CommonHelper::getLocationFieldFromHeader($header)) === FALSE) + { + throw new OrderException('Get order url failed during order creation, the domain list is: '.implode(', ', $this->_domainList)); + } + + $orderInfo = array_merge($body, ['orderUrl' => $orderUrl]); + + $this->populate($orderInfo); + $this->setOrderInfoToCache(['orderUrl' => $orderUrl]); + $this->getAuthorizationList(); + + return $orderInfo; + } + + /** + * Get an existed order info + * @return array + * @throws OrderException + * @throws \stonemax\acme2\exceptions\RequestException + */ + private function getOrder() + { + $orderUrl = $this->getOrderInfoFromCache()['orderUrl']; + + list($code, $header, $body) = RequestHelper::get($orderUrl); + + if ($code != 200) + { + throw new OrderException("Get order info failed, the order url is: {$orderUrl}, the code is: {$code}, the header is: {$header}, the body is: ".print_r($body, TRUE)); + } + + $this->populate(array_merge($body, ['orderUrl' => $orderUrl])); + $this->getAuthorizationList(); + + return array_merge($body, ['orderUrl' => $orderUrl]); + } + + /** + * Get pending challenges info + * @return ChallengeService[] + */ + public function getPendingChallengeList() + { + if ($this->isOrderFinalized() === TRUE || $this->isAllAuthorizationValid() === TRUE) + { + return []; + } + + $challengeList = []; + $thumbprint = OpenSSLHelper::generateThumbprint(); + + foreach ($this->_authorizationList as $authorization) + { + if ($authorization->status != 'pending') + { + continue; + } + + $challengeType = $this->_domainChallengeTypeMap[$authorization->domain]; + $challenge = $authorization->getChallenge($challengeType); + + if ($challenge['status'] != 'pending') + { + continue; + } + + $challengeContent = $challenge['token'].'.'.$thumbprint; + $challengeService = new ChallengeService($challengeType, $authorization); + + /* Generate challenge info for http-01 */ + if ($challengeType == CommonConstant::CHALLENGE_TYPE_HTTP) + { + $challengeCredential = [ + 'identifier' => $authorization->identifier['value'], + 'fileName' => $challenge['token'], + 'fileContent' => $challengeContent, + ]; + } + + /* Generate challenge info for dns-01 */ + else + { + $challengeCredential = [ + 'identifier' => $authorization->identifier['value'], + 'dnsContent' => CommonHelper::base64UrlSafeEncode(hash('sha256', $challengeContent, TRUE)), + ]; + } + + $challengeService->setCredential($challengeCredential); + + $challengeList[] = $challengeService; + } + + return $challengeList; + } + + /** + * Get certificate file path info after verifying + * @param string|null $csr + * @return array + * @throws OrderException + * @throws \stonemax\acme2\exceptions\AccountException + * @throws \stonemax\acme2\exceptions\NonceException + * @throws \stonemax\acme2\exceptions\RequestException + */ + public function getCertificateFile($csr = NULL) + { + if ($this->isAllAuthorizationValid() === FALSE) + { + throw new OrderException("There are still some authorizations that are not valid."); + } + + if ($this->status == 'pending') + { + if (!$csr) + { + $csr = $this->getCSR(); + } + + $this->finalizeOrder(CommonHelper::getCSRWithoutComment($csr)); + } + + while ($this->status != 'valid') + { + sleep(3); + + $this->getOrder(); + } + + list($code, $header, $body) = RequestHelper::get($this->certificate); + + if ($code != 200) + { + throw new OrderException("Fetch certificate from letsencrypt failed, the url is: {$this->certificate}, the domain list is: ".implode(', ', $this->_domainList).", the code is: {$code}, the header is: {$header}, the body is: ".print_r($body, TRUE)); + } + + $certificateMap = CommonHelper::extractCertificate($body); + + file_put_contents($this->_certificatePath, $certificateMap['certificate']); + file_put_contents($this->_certificateFullChainedPath, $certificateMap['certificateFullChained']); + + $certificateInfo = openssl_x509_parse($certificateMap['certificate']); + + $this->setOrderInfoToCache([ + 'validFromTimestamp' => $certificateInfo['validFrom_time_t'], + 'validToTimestamp' => $certificateInfo['validTo_time_t'], + 'validFromTime' => date('Y-m-d H:i:s', $certificateInfo['validFrom_time_t']), + 'validToTime' => date('Y-m-d H:i:s', $certificateInfo['validTo_time_t']), + ]); + + return [ + 'privateKey' => realpath($this->_privateKeyPath), + 'publicKey' => realpath($this->_publicKeyPath), + 'certificate' => realpath($this->_certificatePath), + 'certificateFullChained' => realpath($this->_certificateFullChainedPath), + 'validFromTimestamp' => $certificateInfo['validFrom_time_t'], + 'validToTimestamp' => $certificateInfo['validTo_time_t'], + ]; + } + + /** + * Revoke certificate + * @param int $reason you can find the code in `https://tools.ietf.org/html/rfc5280#section-5.3.1` + * @return bool + * @throws OrderException + * @throws \stonemax\acme2\exceptions\NonceException + * @throws \stonemax\acme2\exceptions\OpenSSLException + * @throws \stonemax\acme2\exceptions\RequestException + */ + public function revokeCertificate($reason = 0) + { + if ($this->status != 'valid') + { + throw new OrderException("Revoke certificate failed because of invalid status({$this->status})"); + } + + if (!is_file($this->_certificatePath)) + { + throw new OrderException("Revoke certificate failed because of certicate file missing({$this->_certificatePath})"); + } + + $certificate = CommonHelper::getCertificateWithoutComment(file_get_contents($this->_certificatePath)); + $certificate = trim(CommonHelper::base64UrlSafeEncode(base64_decode($certificate))); + + $jws = OpenSSLHelper::generateJWSOfJWK( + Client::$runtime->endpoint->revokeCert, + [ + 'certificate' => $certificate, + 'reason' => $reason, + ], + $this->getPrivateKey() + ); + + list($code, $header, $body) = RequestHelper::post(Client::$runtime->endpoint->revokeCert, $jws); + + if ($code != 200) + { + throw new OrderException("Revoke certificate failed, the domain list is: ".implode(', ', $this->_domainList).", the code is: {$code}, the header is: {$header}, the body is: ".print_r($body, TRUE)); + } + + return TRUE; + } + + /** + * Check weather all authorization is valid, if yes, it means all the challenges had passed + * @return bool + */ + public function isAllAuthorizationValid() + { + foreach ($this->_authorizationList as $authorization) + { + if ($authorization->status != 'valid') + { + return FALSE; + } + } + + return TRUE; + } + + /** + * Check weather order had been finalized + * @return bool + */ + public function isOrderFinalized() + { + return ($this->status == 'processing' || $this->status == 'valid'); + } + + /** + * Finalize order to get certificate + * @param string $csr + * @throws OrderException + * @throws \stonemax\acme2\exceptions\AccountException + * @throws \stonemax\acme2\exceptions\NonceException + * @throws \stonemax\acme2\exceptions\RequestException + */ + private function finalizeOrder($csr) + { + $jws = OpenSSLHelper::generateJWSOfKid( + $this->finalize, + Client::$runtime->account->getAccountUrl(), + ['csr' => trim(CommonHelper::base64UrlSafeEncode(base64_decode($csr)))] + ); + + list($code, $header, $body) = RequestHelper::post($this->finalize, $jws); + + if ($code != 200) + { + throw new OrderException("Finalize order failed, the url is: {$this->finalize}, the domain list is: ".implode(', ', $this->_domainList).", the code is: {$code}, the header is: {$header}, the body is: ".print_r($body, TRUE)); + } + + $this->populate($body); + $this->getAuthorizationList(); + } + + /** + * Generate authorization instances according to order info + */ + private function getAuthorizationList() + { + $this->_authorizationList = []; + + foreach ($this->authorizations as $authorizationUrl) + { + $authorization = new AuthorizationService($authorizationUrl); + + $this->_authorizationList[] = $authorization; + } + } + + /** + * Get csr info, if the csr doesn't exist then create it + * @return bool|string + */ + private function getCSR() + { + if (!is_file($this->_csrPath)) + { + $this->createCSRFile(); + } + + return file_get_contents($this->_csrPath); + } + + /** + * Create csr file + */ + private function createCSRFile() + { + $domainList = array_map( + function($identifier) { + return $identifier['value']; + }, + $this->identifiers + ); + + $csr = OpenSSLHelper::generateCSR( + $domainList, + ['commonName' => CommonHelper::getCommonNameForCSR($domainList)], + $this->getPrivateKey() + ); + + file_put_contents($this->_csrPath, $csr); + } + + /** + * Get private key info, if private/public key files doesn't exist then create them + * @return bool|string + * @throws OrderException + * @throws \stonemax\acme2\exceptions\OpenSSLException + */ + private function getPrivateKey() + { + if (!is_file($this->_privateKeyPath) || !is_file($this->_publicKeyPath)) + { + $this->createKeyPairFile(); + } + + return file_get_contents($this->_privateKeyPath); + } + + /** + * Create private/public key files + * @throws OrderException + * @throws \stonemax\acme2\exceptions\OpenSSLException + */ + private function createKeyPairFile() + { + $keyPair = OpenSSLHelper::generateKeyPair($this->_algorithm); + + $result = file_put_contents($this->_privateKeyPath, $keyPair['privateKey']) + && file_put_contents($this->_publicKeyPath, $keyPair['publicKey']); + + if ($result === FALSE) + { + throw new OrderException('Create order key pair files failed, the domain list is: '.implode(', ', $this->_domainList).", the private key path is: {$this->_privateKeyPath}, the public key path is: {$this->_publicKeyPath}"); + } + } + + /** + * Get order basic info from file cache + * @return array + */ + private function getOrderInfoFromCache() + { + $orderInfo = []; + + if (is_file($this->_orderInfoPath)) + { + $orderInfo = json_decode(file_get_contents($this->_orderInfoPath), TRUE); + } + + return $orderInfo ?: []; + } + + /** + * Set order basic info to file cache + * @param array $orderInfo + * @return bool|int + */ + private function setOrderInfoToCache($orderInfo) + { + $orderInfo = array_merge($this->getOrderInfoFromCache(), $orderInfo); + + return file_put_contents($this->_orderInfoPath, json_encode($orderInfo)); + } + + /** + * Populate properties of this instance + * @param array $orderInfo + */ + private function populate($orderInfo) + { + foreach ($orderInfo as $key => $value) + { + $this->{$key} = $value; + } + } +}