Passed
Push — master ( c37c01...0b6156 )
by Alexandr
03:26
created

OrderService::getCertificate()   A

Complexity

Conditions 6
Paths 9

Size

Total Lines 28
Code Lines 15

Duplication

Lines 0
Ratio 0 %

Importance

Changes 2
Bugs 1 Features 1
Metric Value
eloc 15
c 2
b 1
f 1
dl 0
loc 28
rs 9.2222
cc 6
nc 9
nop 3
1
<?php
2
3
declare(strict_types=1);
4
5
/*
6
 * This file is part of the LetsEncrypt ACME client.
7
 *
8
 * @author    Ivanov Aleksandr <[email protected]>
9
 * @copyright 2019
10
 * @license   https://github.com/misantron/letsencrypt-client/blob/master/LICENSE MIT License
11
 */
12
13
namespace LetsEncrypt\Service;
14
15
use GuzzleHttp\Exception\TransferException;
16
use LetsEncrypt\Assertion\Assert;
17
use LetsEncrypt\Certificate\Bundle;
18
use LetsEncrypt\Certificate\Certificate;
19
use LetsEncrypt\Certificate\RevocationReason;
20
use LetsEncrypt\Entity\Account;
21
use LetsEncrypt\Entity\Order;
22
use LetsEncrypt\Exception\EnvironmentException;
23
use LetsEncrypt\Exception\FileIOException;
24
use LetsEncrypt\Exception\OrderException;
25
use LetsEncrypt\Helper\FileSystem;
26
use LetsEncrypt\Helper\KeyGeneratorAwareTrait;
27
use LetsEncrypt\Http\ConnectorAwareTrait;
28
use LetsEncrypt\Http\Response;
29
30
class OrderService
31
{
32
    use ConnectorAwareTrait;
33
    use KeyGeneratorAwareTrait;
34
35
    /**
36
     * @var AuthorizationService
37
     */
38
    private $authorizationService;
39
40
    /**
41
     * @var string
42
     */
43
    private $filesPath;
44
45
    public function __construct(AuthorizationService $authorizationService, string $filesPath)
46
    {
47
        Assert::directory($filesPath, 'Certificates directory path %s is not a directory');
48
49
        $this->authorizationService = $authorizationService;
50
        $this->filesPath = rtrim($filesPath, DIRECTORY_SEPARATOR);
51
    }
52
53
    /**
54
     * @throws OrderException
55
     */
56
    public function create(Account $account, string $basename, array $subjects, Certificate $certificate): Order
57
    {
58
        $this->processOrderBasePath($basename);
59
60
        $identifiers = array_map(static function (string $subject) {
61
            return [
62
                'type' => 'dns',
63
                'value' => $subject,
64
            ];
65
        }, $subjects);
66
67
        $payload = [
68
            'identifiers' => $identifiers,
69
            'notBefore' => $certificate->getNotBefore(),
70
            'notAfter' => $certificate->getNotAfter(),
71
        ];
72
73
        try {
74
            $response = $this->connector->signedKIDRequest(
75
                $account->getUrl(),
76
                $this->connector->getNewOrderUrl(),
77
                $payload,
78
                $account->getPrivateKeyPath()
79
            );
80
        } catch (TransferException $e) {
81
            $this->cleanupFiles($basename);
82
            throw new OrderException('Unable to create order');
83
        }
84
85
        $orderUrl = $response->getLocation();
86
87
        try {
88
            FileSystem::writeFileContent(
89
                $this->getOrderFilePath($basename),
90
                $orderUrl
91
            );
92
        } catch (FileIOException $e) {
93
            throw new OrderException('Unable to store order file');
94
        }
95
96
        $certificate->generate(
97
            $this->keyGenerator,
98
            $this->getPrivateKeyPath($basename),
99
            $this->getPublicKeyPath($basename)
100
        );
101
102
        return $this->createOrderFromResponse($response, $orderUrl);
103
    }
104
105
    /**
106
     * @throws OrderException
107
     */
108
    public function get(string $basename, array $subjects): Order
109
    {
110
        $orderFilePath = $this->getOrderFilePath($basename);
111
112
        Assert::fileExists($orderFilePath, 'Order file %s does not exist');
113
        Assert::readable($orderFilePath, 'Order file %s is not readable');
114
115
        try {
116
            $orderUrl = FileSystem::readFileContent($orderFilePath);
117
        } catch (FileIOException $e) {
118
            throw new OrderException('Unable to get order url');
119
        }
120
121
        $response = $this->connector->get($orderUrl);
122
123
        $order = $this->createOrderFromResponse($response, $orderUrl);
124
125
        if ($order->isInvalid()) {
126
            throw new OrderException('Order has invalid status');
127
        }
128
        if (!$order->isIdentifiersEqual($subjects)) {
129
            throw new OrderException('Order data is invalid - subjects are not equal');
130
        }
131
132
        return $order;
133
    }
134
135
    public function getOrCreate(Account $account, string $basename, array $subjects, Certificate $certificate): Order
136
    {
137
        try {
138
            $order = $this->get($basename, $subjects);
139
        } catch (\Throwable $e) {
140
            $this->cleanupFiles($basename);
141
            $order = $this->create($account, $basename, $subjects, $certificate);
142
        }
143
144
        return $order;
145
    }
146
147
    public function getPendingHttpAuthorizations(Account $account, Order $order): array
148
    {
149
        return $this->authorizationService->getPendingHttpAuthorizations(
150
            $order->getPendingAuthorizations(),
151
            $this->connector->getSigner()->kty($account->getPrivateKeyPath())
152
        );
153
    }
154
155
    public function getPendingDnsAuthorizations(Account $account, Order $order): array
156
    {
157
        return $this->authorizationService->getPendingDnsAuthorizations(
158
            $order->getPendingAuthorizations(),
159
            $this->connector->getSigner()->kty($account->getPrivateKeyPath())
160
        );
161
    }
162
163
    public function verifyPendingHttpAuthorization(Account $account, Order $order, string $identifier): bool
164
    {
165
        $authorizations = $order->getAuthorizations();
166
167
        return $this->authorizationService->verifyPendingHttpAuthorization($account, $authorizations, $identifier);
168
    }
169
170
    public function verifyPendingDnsAuthorization(Account $account, Order $order, string $identifier): bool
171
    {
172
        $authorizations = $order->getAuthorizations();
173
174
        return $this->authorizationService->verifyPendingDnsAuthorization($account, $authorizations, $identifier);
175
    }
176
177
    /**
178
     * @throws OrderException
179
     */
180
    public function getCertificate(Account $account, Order $order, string $basename): void
181
    {
182
        if (!$order->allAuthorizationsValid()) {
183
            throw new OrderException('Order authorizations are not valid');
184
        }
185
186
        if ($order->isPending() || $order->isReady()) {
187
            $order = $this->finalize($account, $order, $basename);
188
        }
189
190
        while ($order->isProcessing()) {
191
            sleep(5);
192
            $response = $this->connector->get($order->getUrl());
193
            $order = $this->createOrderFromResponse($response, $order->getUrl());
194
        }
195
196
        if (!$order->isValid()) {
197
            throw new OrderException('Order status is invalid');
198
        }
199
200
        $response = $this->connector->signedKIDRequest(
201
            $account->getUrl(),
202
            $order->getCertificateRequestUrl(),
203
            [],
204
            $account->getPrivateKeyPath()
205
        );
206
207
        $this->extractAndStoreCertificates($basename, $response->getRawContent());
208
    }
209
210
    public function revokeCertificate(Account $account, string $basename, RevocationReason $reason = null): bool
211
    {
212
        $certificatePrivateKeyPath = $this->getPrivateKeyPath($basename);
213
        Assert::fileExists($certificatePrivateKeyPath);
214
215
        $certificatePath = $this->getCertificatePath($basename);
216
        Assert::fileExists($certificatePath);
217
        Assert::readable($certificatePath);
218
219
        if ($reason === null) {
220
            $reason = RevocationReason::unspecified();
221
        }
222
223
        $certificateContent = FileSystem::readFileContent($certificatePath);
224
225
        preg_match('~-----BEGIN\sCERTIFICATE-----(.*)-----END\sCERTIFICATE-----~s', $certificateContent, $matches);
226
        $encodedCertificate = $this->connector
227
            ->getSigner()
228
            ->getBase64Encoder()
229
            ->encode(base64_decode(trim($matches[1])));
230
231
        $payload = [
232
            'certificate' => $encodedCertificate,
233
            'reason' => $reason,
234
        ];
235
236
        $response = $this->connector->signedKIDRequest(
237
            $this->connector->getRevokeCertificateUrl(),
238
            $account->getUrl(),
239
            $payload,
240
            $certificatePrivateKeyPath
241
        );
242
243
        return $response->isStatusOk();
244
    }
245
246
    private function extractAndStoreCertificates(string $basename, string $content): void
247
    {
248
        $pattern = '~(-----BEGIN\sCERTIFICATE-----[\s\S]+?-----END\sCERTIFICATE-----)~i';
249
250
        if (preg_match_all($pattern, $content, $matches) !== false) {
251
            $certificateContent = $matches[0][0];
252
253
            FileSystem::writeFileContent(
254
                $this->getCertificatePath($basename),
255
                $certificateContent
256
            );
257
258
            $partsCount = count($matches[0]);
259
260
            if ($partsCount > 1) {
261
                $fullChainContent = '';
262
                for ($i = 1; $i < $partsCount; ++$i) {
263
                    $fullChainContent .= PHP_EOL . $matches[0][$i];
264
                }
265
266
                FileSystem::writeFileContent(
267
                    $this->getFullChainCertificatePath($basename),
268
                    $fullChainContent
269
                );
270
            }
271
        }
272
    }
273
274
    private function finalize(Account $account, Order $order, string $basename): Order
275
    {
276
        $csr = $this->keyGenerator->csr(
277
            $basename,
278
            $order->getIdentifiers(),
279
            $this->getPrivateKeyPath($basename)
280
        );
281
282
        $payload = [
283
            'csr' => $this->connector
284
                ->getSigner()
285
                ->getBase64Encoder()
286
                ->encode(base64_decode($csr)),
287
        ];
288
289
        $response = $this->connector->signedKIDRequest(
290
            $account->getUrl(),
291
            $order->getFinalizeUrl(),
292
            $payload,
293
            $account->getPrivateKeyPath()
294
        );
295
296
        return $this->createOrderFromResponse($response, $order->getUrl());
297
    }
298
299
    private function createOrderFromResponse(Response $response, string $url): Order
300
    {
301
        $data = $response->getDecodedContent();
302
        // fetch authorizations data
303
        $data['authorizations'] = $this->authorizationService->getAuthorizations($data['authorizations']);
304
305
        return new Order($data, $url);
306
    }
307
308
    /**
309
     * @throws EnvironmentException
310
     */
311
    private function processOrderBasePath(string $basename): void
312
    {
313
        $basePath = $this->getCertificateBasePath($basename);
314
315
        // try to create certificate directory if it's not exist
316
        if (!is_dir($basePath) && !mkdir($basePath, 0755)) {
317
            throw new EnvironmentException('Unable to create certificate directory "' . $basePath . '"');
318
        }
319
320
        Assert::directory($basePath, 'Certificate directory path %s is not a directory');
321
        Assert::writable($basePath, 'Certificates directory path %s is not writable');
322
    }
323
324
    private function cleanupFiles(string $basename): void
325
    {
326
        $basePath = $this->getCertificateBasePath($basename);
327
        $filesList = scandir($basePath);
328
        if ($filesList !== false) {
329
            array_walk($filesList, static function (string $file) use ($basePath) {
330
                if (is_file($basePath . $file)) {
331
                    unlink($basePath . $file);
332
                }
333
            });
334
        }
335
    }
336
337
    private function getCertificateBasePath(string $basename): string
338
    {
339
        return $this->filesPath . DIRECTORY_SEPARATOR . $basename . DIRECTORY_SEPARATOR;
340
    }
341
342
    public function getOrderFilePath(string $basename): string
343
    {
344
        return $this->getCertificateBasePath($basename) . Bundle::ORDER;
345
    }
346
347
    public function getPrivateKeyPath(string $basename): string
348
    {
349
        return $this->getCertificateBasePath($basename) . Bundle::PRIVATE_KEY;
350
    }
351
352
    public function getPublicKeyPath(string $basename): string
353
    {
354
        return $this->getCertificateBasePath($basename) . Bundle::PUBLIC_KEY;
355
    }
356
357
    public function getCertificatePath(string $basename): string
358
    {
359
        return $this->getCertificateBasePath($basename) . Bundle::CERTIFICATE;
360
    }
361
362
    public function getFullChainCertificatePath(string $basename): string
363
    {
364
        return $this->getCertificateBasePath($basename) . Bundle::FULL_CHAIN_CERTIFICATE;
365
    }
366
}
367