Passed
Push — master ( b7978f...02bb07 )
by Alexandr
02:01
created

OrderService::getCertificateBasePath()   A

Complexity

Conditions 1
Paths 1

Size

Total Lines 3
Code Lines 1

Duplication

Lines 0
Ratio 0 %

Importance

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