Passed
Pull Request — master (#1)
by Alexandr
03:34
created

OrderService::getFullChainCertificatePath()   A

Complexity

Conditions 1
Paths 1

Size

Total Lines 3
Code Lines 1

Duplication

Lines 0
Ratio 0 %

Importance

Changes 0
Metric Value
eloc 1
c 0
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
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\EnvironmentException;
24
use LetsEncrypt\Exception\FileIOException;
25
use LetsEncrypt\Exception\OrderException;
26
use LetsEncrypt\Helper\FileSystem;
27
use LetsEncrypt\Helper\KeyGeneratorAwareTrait;
28
use LetsEncrypt\Http\ConnectorAwareTrait;
29
use LetsEncrypt\Http\Response;
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($response, $orderUrl);
106
    }
107
108
    /**
109
     * @throws OrderException
110
     */
111
    public function get(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->get($orderUrl);
127
128
        $order = $this->createOrderFromResponse($response, $orderUrl);
129
130
        if ($order->isInvalid()) {
131
            throw new OrderException('Order has invalid status');
132
        }
133
        if (!$order->isIdentifiersEqual($subjects)) {
134
            throw new OrderException('Order data is invalid - subjects are not equal');
135
        }
136
137
        return $order;
138
    }
139
140
    public function getOrCreate(Account $account, string $basename, array $subjects, Certificate $certificate): Order
141
    {
142
        $directoryName = self::getDomainDirectoryName($basename, $certificate->getKeyType()->getValue());
143
144
        try {
145
            $order = $this->get($basename, $subjects, $certificate->getKeyType());
146
        } catch (\Throwable $e) {
147
            $this->cleanupFiles($directoryName);
148
            $order = $this->create($account, $basename, $subjects, $certificate);
149
        }
150
151
        return $order;
152
    }
153
154
    public function getPendingHttpAuthorizations(Account $account, Order $order): array
155
    {
156
        return $this->authorizationService->getPendingHttpAuthorizations(
157
            $order->getPendingAuthorizations(),
158
            $this->connector->getSigner()->kty($account->getPrivateKeyPath())
159
        );
160
    }
161
162
    public function getPendingDnsAuthorizations(Account $account, Order $order): array
163
    {
164
        return $this->authorizationService->getPendingDnsAuthorizations(
165
            $order->getPendingAuthorizations(),
166
            $this->connector->getSigner()->kty($account->getPrivateKeyPath())
167
        );
168
    }
169
170
    public function verifyPendingHttpAuthorization(Account $account, Order $order, string $identifier): bool
171
    {
172
        $authorizations = $order->getAuthorizations();
173
174
        return $this->authorizationService->verifyPendingHttpAuthorization($account, $authorizations, $identifier);
175
    }
176
177
    public function verifyPendingDnsAuthorization(Account $account, Order $order, string $identifier): bool
178
    {
179
        $authorizations = $order->getAuthorizations();
180
181
        return $this->authorizationService->verifyPendingDnsAuthorization($account, $authorizations, $identifier);
182
    }
183
184
    /**
185
     * @throws OrderException
186
     */
187
    public function getCertificate(Account $account, Order $order, string $basename, KeyType $keyType): void
188
    {
189
        if (!$order->allAuthorizationsValid()) {
190
            throw new OrderException('Order authorizations are not valid');
191
        }
192
193
        $directoryName = self::getDomainDirectoryName($basename, $keyType->getValue());
194
195
        if ($order->isPending() || $order->isReady()) {
196
            $order = $this->finalize($account, $order, $basename, $directoryName);
197
        }
198
199
        while ($order->isProcessing()) {
200
            sleep(5);
201
            $response = $this->connector->get($order->getUrl());
202
            $order = $this->createOrderFromResponse($response, $order->getUrl());
203
        }
204
205
        if (!$order->isValid()) {
206
            throw new OrderException('Order status is invalid');
207
        }
208
209
        $response = $this->connector->signedKIDRequest(
210
            $account->getUrl(),
211
            $order->getCertificateRequestUrl(),
212
            [],
213
            $account->getPrivateKeyPath()
214
        );
215
216
        $this->extractAndStoreCertificates($directoryName, $response->getRawContent());
217
    }
218
219
    public function revokeCertificate(
220
        Account $account,
221
        string $basename,
222
        KeyType $keyType,
223
        RevocationReason $reason = null
224
    ): bool {
225
        $directoryName = self::getDomainDirectoryName($basename, $keyType->getValue());
226
227
        $certificatePrivateKeyPath = $this->getPrivateKeyPath($directoryName);
228
        Assert::fileExists($certificatePrivateKeyPath);
229
230
        $certificatePath = $this->getCertificatePath($directoryName);
231
        Assert::fileExists($certificatePath);
232
        Assert::readable($certificatePath);
233
234
        if ($reason === null) {
235
            $reason = RevocationReason::unspecified();
236
        }
237
238
        $certificateContent = FileSystem::readFileContent($certificatePath);
239
240
        $pattern = '~-----BEGIN\sCERTIFICATE-----(.*)-----END\sCERTIFICATE-----~s';
241
242
        preg_match($pattern, $certificateContent, $matches);
243
        $encodedCertificate = $this->connector
244
            ->getSigner()
245
            ->getBase64Encoder()
246
            ->encode(base64_decode(trim($matches[1])));
247
248
        $payload = [
249
            'certificate' => $encodedCertificate,
250
            'reason' => $reason,
251
        ];
252
253
        $response = $this->connector->signedKIDRequest(
254
            $this->connector->getRevokeCertificateUrl(),
255
            $account->getUrl(),
256
            $payload,
257
            $certificatePrivateKeyPath
258
        );
259
260
        return $response->isStatusOk();
261
    }
262
263
    private function extractAndStoreCertificates(string $directoryName, string $content): void
264
    {
265
        $pattern = '~(-----BEGIN\sCERTIFICATE-----[\s\S]+?-----END\sCERTIFICATE-----)~i';
266
267
        if (preg_match_all($pattern, $content, $matches) !== false) {
268
            $certificateContent = $matches[0][0];
269
270
            FileSystem::writeFileContent(
271
                $this->getCertificatePath($directoryName),
272
                $certificateContent
273
            );
274
275
            $partsCount = count($matches[0]);
276
277
            if ($partsCount > 1) {
278
                $fullChainContent = '';
279
                for ($i = 1; $i < $partsCount; ++$i) {
280
                    $fullChainContent .= PHP_EOL . $matches[0][$i];
281
                }
282
283
                FileSystem::writeFileContent(
284
                    $this->getFullChainCertificatePath($directoryName),
285
                    $fullChainContent
286
                );
287
            }
288
        }
289
    }
290
291
    private function finalize(Account $account, Order $order, string $basename, string $directoryName): Order
292
    {
293
        $csr = $this->keyGenerator->csr(
294
            $basename,
295
            $order->getIdentifiers(),
296
            $this->getPrivateKeyPath($directoryName)
297
        );
298
299
        $payload = [
300
            'csr' => $this->connector
301
                ->getSigner()
302
                ->getBase64Encoder()
303
                ->encode(base64_decode($csr)),
304
        ];
305
306
        $response = $this->connector->signedKIDRequest(
307
            $account->getUrl(),
308
            $order->getFinalizeUrl(),
309
            $payload,
310
            $account->getPrivateKeyPath()
311
        );
312
313
        return $this->createOrderFromResponse($response, $order->getUrl());
314
    }
315
316
    private function createOrderFromResponse(Response $response, string $url): Order
317
    {
318
        $data = $response->getDecodedContent();
319
        // fetch authorizations data
320
        $data['authorizations'] = $this->authorizationService->getAuthorizations($data['authorizations']);
321
322
        return new Order($data, $url);
323
    }
324
325
    /**
326
     * @throws EnvironmentException
327
     */
328
    private function processOrderBasePath(string $directoryName): void
329
    {
330
        $basePath = $this->getCertificateBasePath($directoryName);
331
332
        // try to create certificate directory if it's not exist
333
        if (!is_dir($basePath) && !mkdir($basePath, 0755)) {
334
            throw new EnvironmentException('Unable to create certificate directory "' . $basePath . '"');
335
        }
336
337
        Assert::directory($basePath, 'Certificate directory path %s is not a directory');
338
        Assert::writable($basePath, 'Certificates directory path %s is not writable');
339
    }
340
341
    private function cleanupFiles(string $directoryName): void
342
    {
343
        $basePath = $this->getCertificateBasePath($directoryName);
344
345
        $filesList = scandir($basePath);
346
        if ($filesList !== false) {
347
            array_walk($filesList, static function (string $file) use ($basePath) {
348
                if (is_file($basePath . $file)) {
349
                    unlink($basePath . $file);
350
                }
351
            });
352
        }
353
    }
354
355
    public static function getDomainDirectoryName(string $basename, string $type): string
356
    {
357
        return $basename . ':' . $type;
358
    }
359
360
    private function getCertificateBasePath(string $directoryName): string
361
    {
362
        return $this->filesPath . DIRECTORY_SEPARATOR . $directoryName . DIRECTORY_SEPARATOR;
363
    }
364
365
    public function getOrderFilePath(string $directoryName): string
366
    {
367
        return $this->getCertificateBasePath($directoryName) . Bundle::ORDER;
368
    }
369
370
    public function getPrivateKeyPath(string $directoryName): string
371
    {
372
        return $this->getCertificateBasePath($directoryName) . Bundle::PRIVATE_KEY;
373
    }
374
375
    public function getPublicKeyPath(string $directoryName): string
376
    {
377
        return $this->getCertificateBasePath($directoryName) . Bundle::PUBLIC_KEY;
378
    }
379
380
    public function getCertificatePath(string $directoryName): string
381
    {
382
        return $this->getCertificateBasePath($directoryName) . Bundle::CERTIFICATE;
383
    }
384
385
    public function getFullChainCertificatePath(string $directoryName): string
386
    {
387
        return $this->getCertificateBasePath($directoryName) . Bundle::FULL_CHAIN_CERTIFICATE;
388
    }
389
}
390