Completed
Pull Request — master (#2)
by John
13:19
created

LEOrder::getCertificate()   B

Complexity

Conditions 6
Paths 6

Size

Total Lines 34
Code Lines 21

Duplication

Lines 0
Ratio 0 %

Code Coverage

Tests 20
CRAP Score 6

Importance

Changes 1
Bugs 0 Features 0
Metric Value
cc 6
eloc 21
c 1
b 0
f 0
nc 6
nop 0
dl 0
loc 34
ccs 20
cts 20
cp 1
crap 6
rs 8.9617
1
<?php
2
3
namespace Zwartpet\PHPCertificateToolbox;
4
5
use Zwartpet\PHPCertificateToolbox\DNSValidator\DNSValidatorInterface;
6
use Zwartpet\PHPCertificateToolbox\Exception\LogicException;
7
use Zwartpet\PHPCertificateToolbox\Exception\RuntimeException;
8
use Psr\Log\LoggerInterface;
9
10
/**
11
 * LetsEncrypt Order class, containing the functions and data associated with a specific LetsEncrypt order.
12
 *
13
 * @author     Youri van Weegberg <[email protected]>
14
 * @copyright  2018 Youri van Weegberg
15
 * @license    https://opensource.org/licenses/mit-license.php  MIT License
16
 */
17
class LEOrder
18
{
19
    const CHALLENGE_TYPE_HTTP = 'http-01';
20
    const CHALLENGE_TYPE_DNS = 'dns-01';
21
22
    /** @var string order status (pending, processing, valid) */
23
    private $status;
24
25
    /** @var string expiration date for order */
26
    private $expires;
27
28
    /** @var array containing all the domain identifiers for the order */
29
    private $identifiers;
30
31
    /** @var string[] URLs to all the authorization objects for this order */
32
    private $authorizationURLs;
33
34
    /** @var LEAuthorization[] array of authorization objects for the order */
35
    private $authorizations;
36
37
    /** @var string URL for order finalization */
38
    private $finalizeURL;
39
40
    /** @var string URL for obtaining certificate */
41
    private $certificateURL;
42
43
    /** @var string base domain name for certificate */
44
    private $basename;
45
46
    /** @var string URL referencing order */
47
    private $orderURL;
48
49
    /** @var string type of key (rsa or ec) */
50
    private $keyType;
51
52
    /** @var int size of key (typically 2048 or 4096 for rsa, 256 or 384 for ec */
53
    private $keySize;
54
55
    /** @var LEConnector ACME API connection provided to constructor */
56
    private $connector;
57
58
    /** @var LoggerInterface logger provided to constructor */
59
    private $log;
60
61
    /** @var DNSValidatorInterface dns resolution provider to constructor*/
62
    private $dns;
63
64
    /** @var Sleep sleep service provided to constructor */
65
    private $sleep;
66
67
    /** @var CertificateStorageInterface storage interface provided to constructor */
68
    private $storage;
69
70
    /**
71
     * Initiates the LetsEncrypt Order class. If the base name is found in the $keysDir directory, the order data is
72
     * requested. If no order was found locally, if the request is invalid or when there is a change in domain names, a
73
     * new order is created.
74
     *
75
     * @param LEConnector $connector The LetsEncrypt Connector instance to use for HTTP requests.
76
     * @param CertificateStorageInterface $storage
77
     * @param LoggerInterface $log PSR-3 compatible logger
78
     * @param DNSValidatorInterface $dns DNS challenge checking service
79
     * @param Sleep $sleep Sleep service for polling
80
     */
81 72
    public function __construct(
82
        LEConnector $connector,
83
        CertificateStorageInterface $storage,
84
        LoggerInterface $log,
85
        DNSValidatorInterface $dns,
86
        Sleep $sleep
87
    ) {
88
89 72
        $this->connector = $connector;
90 72
        $this->log = $log;
91 72
        $this->dns = $dns;
92 72
        $this->sleep = $sleep;
93 72
        $this->storage = $storage;
94 72
    }
95
96
    /**
97
     * Loads or updates an order. If the base name is found in the $keysDir directory, the order data is
98
     * requested. If no order was found locally, if the request is invalid or when there is a change in domain names, a
99
     * new order is created.
100
     *
101
     * @param string $basename The base name for the order. Preferable the top domain (example.org).
102
     *                                         Will be the directory in which the keys are stored. Used for the
103
     *                                         CommonName in the certificate as well.
104
     * @param array $domains The array of strings containing the domain names on the certificate.
105
     * @param string $keyType Type of the key we want to use for certificate. Can be provided in
106
     *                                         ALGO-SIZE format (ex. rsa-4096 or ec-256) or simply "rsa" and "ec"
107
     *                                         (using default sizes)
108
     * @param string $notBefore A date string formatted like 0000-00-00T00:00:00Z (yyyy-mm-dd hh:mm:ss)
109
     *                                         at which the certificate becomes valid.
110
     * @param string $notAfter A date string formatted like 0000-00-00T00:00:00Z (yyyy-mm-dd hh:mm:ss)
111
     *                                         until which the certificate is valid.
112
     */
113 72
    public function loadOrder($basename, array $domains, $keyType, $notBefore, $notAfter)
114
    {
115 72
        $this->basename = $basename;
116
117 72
        $this->initialiseKeyTypeAndSize($keyType ?? 'rsa-4096');
118
119 68
        if ($this->loadExistingOrder($domains)) {
120 8
            $this->updateAuthorizations();
121
        } else {
122 68
            $this->createOrder($domains, $notBefore, $notAfter);
123
        }
124 60
    }
125
126 68
    private function loadExistingOrder($domains)
127
    {
128 68
        $orderUrl = $this->storage->getMetadata($this->basename.'.order.url');
129 68
        $publicKey = $this->storage->getPublicKey($this->basename);
130 68
        $privateKey = $this->storage->getPrivateKey($this->basename);
131
132
        //anything to load?
133 68
        if (empty($orderUrl) || empty($publicKey) || empty($privateKey)) {
134 68
            $this->log->info("No order found for {$this->basename}. Creating new order.");
135 68
            return false;
136
        }
137
138
        //valid URL?
139 16
        $this->orderURL = $orderUrl;
140 16
        if (!filter_var($this->orderURL, FILTER_VALIDATE_URL)) {
141
            //@codeCoverageIgnoreStart
142
            $this->log->warning("Order for {$this->basename} has invalid URL. Creating new order.");
143
            $this->deleteOrderFiles();
144
            return false;
145
            //@codeCoverageIgnoreEnd
146
        }
147
148
        //retrieve the order
149 16
        $sign = $this->connector->signRequestKid(
150 16
            null,
151
            $this->connector->accountURL,
152
            $this->orderURL
153
        );
154
155
        $post = $this->connector->post($this->orderURL, $sign);
156
        if ($post['status'] !== 200) {
157
            //@codeCoverageIgnoreStart
158
            $this->log->warning("Order for {$this->basename} could not be loaded. Creating new order.");
159 16
            $this->deleteOrderFiles();
160 4
            return false;
161 4
            //@codeCoverageIgnoreEnd
162 4
        }
163
164
        //ensure the order is still valid
165
        if ($post['body']['status'] === 'invalid') {
166 12
            $this->log->warning("Order for {$this->basename} is 'invalid', unable to authorize. Creating new order.");
167 12
            $this->deleteOrderFiles();
168 12
            return false;
169 12
        }
170 12
171 4
        //ensure retrieved order matches our domains
172 4
        $orderdomains = array_map(function ($ident) {
173 4
            return $ident['value'];
174
        }, $post['body']['identifiers']);
175
        $diff = array_merge(array_diff($orderdomains, $domains), array_diff($domains, $orderdomains));
176
        if (!empty($diff)) {
177 8
            $this->log->warning('Domains do not match order data. Deleting and creating new order.');
178 8
            $this->deleteOrderFiles();
179 8
            return false;
180 8
        }
181 8
182 8
        //the order is good
183 4
        $this->status = $post['body']['status'];
184
        $this->expires = $post['body']['expires'];
185
        $this->identifiers = $post['body']['identifiers'];
186 8
        $this->authorizationURLs = $post['body']['authorizations'];
187
        $this->finalizeURL = $post['body']['finalize'];
188
        if (array_key_exists('certificate', $post['body'])) {
0 ignored issues
show
Bug introduced by
It seems like $post['body'] can also be of type Psr\Http\Message\StreamInterface; however, parameter $search of array_key_exists() does only seem to accept array, maybe add an additional type check? ( Ignorable by Annotation )

If this is a false-positive, you can also ignore this issue in your code via the ignore-type  annotation

188
        if (array_key_exists('certificate', /** @scrutinizer ignore-type */ $post['body'])) {
Loading history...
189 8
            $this->certificateURL = $post['body']['certificate'];
190
        }
191 8
192 8
        return true;
193 8
    }
194 8
195 8
    private function deleteOrderFiles()
196 8
    {
197
        $this->storage->setPrivateKey($this->basename, null);
198 72
        $this->storage->setPublicKey($this->basename, null);
199
        $this->storage->setCertificate($this->basename, null);
200 72
        $this->storage->setFullChainCertificate($this->basename, null);
201 20
        $this->storage->setMetadata($this->basename.'.order.url', null);
202 20
    }
203 52
204 32
    private function initialiseKeyTypeAndSize($keyType)
205 32
    {
206
        if ($keyType == 'rsa') {
207 20
            $this->keyType = 'rsa';
208
            $this->keySize = 4096;
209 20
        } elseif ($keyType == 'ec') {
210 16
            $this->keyType = 'ec';
211 16
            $this->keySize = 256;
212
        } else {
213 4
            preg_match_all('/^(rsa|ec)\-([0-9]{3,4})$/', $keyType, $keyTypeParts, PREG_SET_ORDER, 0);
214
215
            if (!empty($keyTypeParts)) {
216 68
                $this->keyType = $keyTypeParts[0][1];
217
                $this->keySize = intval($keyTypeParts[0][2]);
218
            } else {
219
                throw new LogicException('Key type \'' . $keyType . '\' not supported.');
220
            }
221
        }
222
    }
223
224
    /**
225
     * Creates a new LetsEncrypt order and fills this instance with its data. Subsequently creates a new RSA keypair
226
     * for the certificate.
227
     *
228 68
     * @param array $domains The array of strings containing the domain names on the certificate.
229
     * @param string $notBefore A date string formatted like 0000-00-00T00:00:00Z (yyyy-mm-dd hh:mm:ss)
230 68
     *                          at which the certificate becomes valid.
231 68
     * @param string $notAfter A date string formatted like 0000-00-00T00:00:00Z (yyyy-mm-dd hh:mm:ss)
232
     *                          until which the certificate is valid.
233 4
     */
234
    private function createOrder($domains, $notBefore, $notAfter)
235
    {
236 64
        if (!preg_match('~(\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}Z|^$)~', $notBefore) ||
237 64
            !preg_match('~(\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}Z|^$)~', $notAfter)
238 64
        ) {
239 4
            throw new LogicException("notBefore and notAfter must be blank or iso-8601 datestamp");
240
        }
241 60
242
        $dns = [];
243 60
        foreach ($domains as $domain) {
244 60
            if (preg_match_all('~(\*\.)~', $domain) > 1) {
245 60
                throw new LogicException('Cannot create orders with multiple wildcards in one domain.');
246 60
            }
247 60
            $dns[] = ['type' => 'dns', 'value' => $domain];
248
        }
249 60
        $payload = ["identifiers" => $dns, 'notBefore' => $notBefore, 'notAfter' => $notAfter];
250 60
        $sign = $this->connector->signRequestKid(
251
            $payload,
252
            $this->connector->accountURL,
253
            $this->connector->newOrder
254
        );
255
        $post = $this->connector->post($this->connector->newOrder, $sign);
256 60
        if ($post['status'] !== 201) {
257
            //@codeCoverageIgnoreStart
258
            throw new RuntimeException('Creating new order failed.');
259
            //@codeCoverageIgnoreEnd
260
        }
261
262 60
        if (!preg_match('~Location: (\S+)~i', $post['header'], $matches)) {
263 60
            //@codeCoverageIgnoreStart
264
            throw new RuntimeException('New-order returned invalid response.');
265 60
            //@codeCoverageIgnoreEnd
266
        }
267 60
268 60
        $this->orderURL = trim($matches[1]);
269 60
        $this->storage->setMetadata($this->basename.'.order.url', $this->orderURL);
270 60
271 60
        $this->generateKeys();
272 60
273 40
        $this->status = $post['body']['status'];
274
        $this->expires = $post['body']['expires'];
275 60
        $this->identifiers = $post['body']['identifiers'];
276
        $this->authorizationURLs = $post['body']['authorizations'];
277 60
        $this->finalizeURL = $post['body']['finalize'];
278 60
        if (array_key_exists('certificate', $post['body'])) {
0 ignored issues
show
Bug introduced by
It seems like $post['body'] can also be of type Psr\Http\Message\StreamInterface; however, parameter $search of array_key_exists() does only seem to accept array, maybe add an additional type check? ( Ignorable by Annotation )

If this is a false-positive, you can also ignore this issue in your code via the ignore-type  annotation

278
        if (array_key_exists('certificate', /** @scrutinizer ignore-type */ $post['body'])) {
Loading history...
279
            $this->certificateURL = $post['body']['certificate'];
280 60
        }
281
        $this->updateAuthorizations();
282 60
283 32
        $this->log->info('Created order for ' . $this->basename);
284
    }
285 28
286
    private function generateKeys()
287
    {
288 60
        if ($this->keyType == "rsa") {
289 60
            $key = LEFunctions::RSAgenerateKeys($this->keySize);
290 60
        } else {
291
            $key = LEFunctions::ECgenerateKeys($this->keySize);
292
        }
293
294
        $this->storage->setPublicKey($this->basename, $key['public']);
295 28
        $this->storage->setPrivateKey($this->basename, $key['private']);
296
    }
297 28
298 28
    /**
299 28
     * Fetches the latest data concerning this LetsEncrypt Order instance and fills this instance with the new data.
300 28
     */
301 28
    private function updateOrderData()
302 28
    {
303 28
        $sign = $this->connector->signRequestKid(
304 28
            null,
305 28
            $this->connector->accountURL,
306
            $this->orderURL
307 28
        );
308
309
        $post = $this->connector->post($this->orderURL, $sign);
310
        if (strpos($post['header'], "200 OK") !== false) {
311
            $this->status = $post['body']['status'];
312
            $this->expires = $post['body']['expires'];
313 28
            $this->identifiers = $post['body']['identifiers'];
314
            $this->authorizationURLs = $post['body']['authorizations'];
315
            $this->finalizeURL = $post['body']['finalize'];
316
            if (array_key_exists('certificate', $post['body'])) {
0 ignored issues
show
Bug introduced by
It seems like $post['body'] can also be of type Psr\Http\Message\StreamInterface; however, parameter $search of array_key_exists() does only seem to accept array, maybe add an additional type check? ( Ignorable by Annotation )

If this is a false-positive, you can also ignore this issue in your code via the ignore-type  annotation

316
            if (array_key_exists('certificate', /** @scrutinizer ignore-type */ $post['body'])) {
Loading history...
317
                $this->certificateURL = $post['body']['certificate'];
318
            }
319 60
            $this->updateAuthorizations();
320
        } else {
321 60
            //@codeCoverageIgnoreStart
322 60
            $this->log->error("Failed to fetch order for {$this->basename}");
323 56
            //@codeCoverageIgnoreEnd
324 56
        }
325 56
    }
326 56
327
    /**
328
     * Fetches the latest data concerning all authorizations connected to this LetsEncrypt Order instance and
329
     * creates and stores a new LetsEncrypt Authorization instance for each one.
330 60
     */
331
    private function updateAuthorizations()
332
    {
333
        $this->authorizations = [];
334
        foreach ($this->authorizationURLs as $authURL) {
335
            if (filter_var($authURL, FILTER_VALIDATE_URL)) {
336
                $auth = new LEAuthorization($this->connector, $this->log, $authURL);
337 6
                if ($auth != false) {
338
                    $this->authorizations[] = $auth;
339 6
                }
340 2
            }
341 2
        }
342 2
    }
343
344
    /**
345 2
     * Walks all LetsEncrypt Authorization instances and returns whether they are all valid (verified).
346
     *
347 4
     * @return boolean  Returns true if all authorizations are valid (verified), returns false if not.
348
     */
349
    public function allAuthorizationsValid()
350 6
    {
351
        if (count($this->authorizations) > 0) {
352 6
            foreach ($this->authorizations as $auth) {
353 6
                if ($auth->status != 'valid') {
354 6
                    return false;
355
                }
356
            }
357
            return true;
358
        }
359 6
        return false;
360
    }
361
362
    private function loadAccountKey()
363 2
    {
364
        $keydata = $this->storage->getAccountPrivateKey();
365 2
        $privateKey = openssl_pkey_get_private($keydata);
366 2
        if ($privateKey === false) {
367 2
            //@codeCoverageIgnoreStart
368
            throw new RuntimeException("Failed load account key");
369
            //@codeCoverageIgnoreEnd
370
        }
371
        return $privateKey;
372 2
    }
373
374
375
    private function loadCertificateKey()
376
    {
377
        $keydata = $this->storage->getPrivateKey($this->basename);
378
        $privateKey = openssl_pkey_get_private($keydata);
379
        if ($privateKey === false) {
380
            //@codeCoverageIgnoreStart
381
            throw new RuntimeException("Failed load certificate key");
382
            //@codeCoverageIgnoreEnd
383
        }
384
        return $privateKey;
385
    }
386
387
    /**
388
     * Get all pending LetsEncrypt Authorization instances and return the necessary data for verification.
389
     * The data in the return object depends on the $type.
390
     *
391
     * @param string $type The type of verification to get. Supporting http-01 and dns-01.
392 6
     *                     Supporting LEOrder::CHALLENGE_TYPE_HTTP and LEOrder::CHALLENGE_TYPE_DNS. Throws a Runtime
393
     *                     Exception when requesting an unknown $type. Keep in mind a wildcard domain authorization only
394 6
     *                     accepts LEOrder::CHALLENGE_TYPE_DNS.
395
     *
396 6
     * @return array|bool Returns an array with verification data if successful, false if not pending LetsEncrypt
397 6
     *                  Authorization instances were found. The return array always
398
     *                  contains 'type' and 'identifier'. For LEOrder::CHALLENGE_TYPE_HTTP, the array contains
399
     *                  'filename' and 'content' for necessary the authorization file.
400 6
     *                  For LEOrder::CHALLENGE_TYPE_DNS, the array contains 'DNSDigest', which is the content for the
401 6
     *                  necessary DNS TXT entry.
402 6
     */
403
404
    public function getPendingAuthorizations($type)
405 6
    {
406
        $authorizations = [];
407 6
408 6
        $privateKey = $this->loadAccountKey();
409 6
        $details = openssl_pkey_get_details($privateKey);
410 6
411 6
        $header = [
412 6
            "e" => LEFunctions::base64UrlSafeEncode($details["rsa"]["e"]),
413 3
            "kty" => "RSA",
414 4
            "n" => LEFunctions::base64UrlSafeEncode($details["rsa"]["n"])
415 4
416 4
        ];
417 4
        $digest = LEFunctions::base64UrlSafeEncode(hash('sha256', json_encode($header), true));
418 4
419
        foreach ($this->authorizations as $auth) {
420 4
            if ($auth->status == 'pending') {
421 1
                $challenge = $auth->getChallenge($type);
422 2
                if ($challenge['status'] == 'pending') {
423 2
                    $keyAuthorization = $challenge['token'] . '.' . $digest;
424
                    switch (strtolower($type)) {
425 2
                        case LEOrder::CHALLENGE_TYPE_HTTP:
426 2
                            $authorizations[] = [
427 2
                                'type' => LEOrder::CHALLENGE_TYPE_HTTP,
428 2
                                'identifier' => $auth->identifier['value'],
429
                                'filename' => $challenge['token'],
430 4
                                'content' => $keyAuthorization
431
                            ];
432
                            break;
433
                        case LEOrder::CHALLENGE_TYPE_DNS:
434
                            $DNSDigest = LEFunctions::base64UrlSafeEncode(
435
                                hash('sha256', $keyAuthorization, true)
436 6
                            );
437
                            $authorizations[] = [
438
                                'type' => LEOrder::CHALLENGE_TYPE_DNS,
439
                                'identifier' => $auth->identifier['value'],
440
                                'DNSDigest' => $DNSDigest
441
                            ];
442
                            break;
443
                    }
444
                }
445
            }
446
        }
447
448
        return count($authorizations) > 0 ? $authorizations : false;
449
    }
450 2
451
    /**
452 2
     * Sends a verification request for a given $identifier and $type. The function itself checks whether the
453 2
     * verification is valid before making the request.
454
     * Updates the LetsEncrypt Authorization instances after a successful verification.
455
     *
456 2
     * @param string $identifier The domain name to verify.
457 2
     * @param int $type The type of verification. Supporting LEOrder::CHALLENGE_TYPE_HTTP and
458 2
     *                           LEOrder::CHALLENGE_TYPE_DNS.
459
     *
460 2
     * @return boolean  Returns true when the verification request was successful, false if not.
461
     */
462 2
    public function verifyPendingOrderAuthorization($identifier, $type)
463 2
    {
464 2
        $privateKey = $this->loadAccountKey();
465 2
        $details = openssl_pkey_get_details($privateKey);
466 2
467 2
        $header = [
468 1
            "e" => LEFunctions::base64UrlSafeEncode($details["rsa"]["e"]),
469 1
            "kty" => "RSA",
470
            "n" => LEFunctions::base64UrlSafeEncode($details["rsa"]["n"])
471 1
        ];
472 2
        $digest = LEFunctions::base64UrlSafeEncode(hash('sha256', json_encode($header), true));
473
474
        foreach ($this->authorizations as $auth) {
475
            if ($auth->identifier['value'] == $identifier) {
476
                if ($auth->status == 'pending') {
477
                    $challenge = $auth->getChallenge($type);
478
                    if ($challenge['status'] == 'pending') {
479
                        $keyAuthorization = $challenge['token'] . '.' . $digest;
480
                        switch ($type) {
481
                            case LEOrder::CHALLENGE_TYPE_HTTP:
482
                                return $this->verifyHTTPChallenge($identifier, $challenge, $keyAuthorization, $auth);
483
                            case LEOrder::CHALLENGE_TYPE_DNS:
484
                                return $this->verifyDNSChallenge($identifier, $challenge, $keyAuthorization, $auth);
485 2
                        }
486
                    }
487
                }
488 2
            }
489 2
        }
490
491
        //f we reach here, the domain identifier given did not match any authorization object
492
        //@codeCoverageIgnoreStart
493
        throw new LogicException("Attempt to verify authorization for identifier $identifier not in order");
494
        //@codeCoverageIgnoreEnd
495 2
    }
496 2
497 2
    private function verifyDNSChallenge($identifier, array $challenge, $keyAuthorization, LEAuthorization $auth)
498 2
    {
499
        //check it ourselves
500 2
        $DNSDigest = LEFunctions::base64UrlSafeEncode(hash('sha256', $keyAuthorization, true));
501 2
        if (!$this->dns->checkChallenge($identifier, $DNSDigest)) {
502
            $this->log->warning("DNS challenge for $identifier tested, found invalid.");
503
            return false;
504
        }
505
506 2
        //ask LE to check
507 2
        $sign = $this->connector->signRequestKid(
508 2
            ['keyAuthorization' => $keyAuthorization],
509 2
            $this->connector->accountURL,
510
            $challenge['url']
511 2
        );
512
        $post = $this->connector->post($challenge['url'], $sign);
513 2
        if ($post['status'] !== 200) {
514
            $this->log->warning("DNS challenge for $identifier valid, but failed to post to ACME service");
515
            return false;
516
        }
517
518
        while ($auth->status == 'pending') {
519
            $this->log->notice("DNS challenge for $identifier valid - waiting for confirmation");
520
            $this->sleep->for(1);
521
            $auth->updateData();
522
        }
523
        $this->log->notice("DNS challenge for $identifier validated");
524
525
        return true;
526
    }
527
528
    private function verifyHTTPChallenge($identifier, array $challenge, $keyAuthorization, LEAuthorization $auth)
529
    {
530
        if (!$this->connector->checkHTTPChallenge($identifier, $challenge['token'], $keyAuthorization)) {
531
            $this->log->warning("HTTP challenge for $identifier tested, found invalid.");
532
            return false;
533
        }
534
535
        $sign = $this->connector->signRequestKid(
536
            ['keyAuthorization' => $keyAuthorization],
537
            $this->connector->accountURL,
538
            $challenge['url']
539
        );
540
541
        $post = $this->connector->post($challenge['url'], $sign);
542
        if ($post['status'] !== 200) {
543
            //@codeCoverageIgnoreStart
544
            $this->log->warning("HTTP challenge for $identifier valid, but failed to post to ACME service");
545
            return false;
546
            //@codeCoverageIgnoreEnd
547
        }
548
549
        while ($auth->status == 'pending') {
550
            $this->log->notice("HTTP challenge for $identifier valid - waiting for confirmation");
551
            $this->sleep->for(1);
552
            $auth->updateData();
553
        }
554
        $this->log->notice("HTTP challenge for $identifier validated");
555
        return true;
556
    }
557
558
    /*
559
     * Deactivate an LetsEncrypt Authorization instance.
560
     *
561
     * @param string $identifier The domain name for which the verification should be deactivated.
562
     *
563
     * @return boolean  Returns true is the deactivation request was successful, false if not.
564
     */
565
    /*
566
    public function deactivateOrderAuthorization($identifier)
567
    {
568
        foreach ($this->authorizations as $auth) {
569
            if ($auth->identifier['value'] == $identifier) {
570
                $sign = $this->connector->signRequestKid(
571
                    ['status' => 'deactivated'],
572
                    $this->connector->accountURL,
573
                    $auth->authorizationURL
574
                );
575
                $post = $this->connector->post($auth->authorizationURL, $sign);
576
                if (strpos($post['header'], "200 OK") !== false) {
577
                    $this->log->info('Authorization for \'' . $identifier . '\' deactivated.');
578
                    $this->updateAuthorizations();
579
                    return true;
580
                }
581
            }
582
        }
583
584
        $this->log->warning('No authorization found for \'' . $identifier . '\', cannot deactivate.');
585
586
        return false;
587
    }
588 2
    */
589 2
590 2
    /**
591
     * Generates a Certificate Signing Request for the identifiers in the current LetsEncrypt Order instance.
592 2
     * If possible, the base name will be the certificate common name and all domain names in this LetsEncrypt Order
593
     * instance will be added to the Subject Alternative Names entry.
594 2
     *
595 2
     * @return string   Returns the generated CSR as string, unprepared for LetsEncrypt. Preparation for the request
596 2
     *                  happens in finalizeOrder()
597 2
     */
598 2
    private function generateCSR()
599
    {
600
        $domains = array_map(function ($dns) {
601
            return $dns['value'];
602
        }, $this->identifiers);
603 2
604 2
        $dn = ["commonName" => $this->calcCommonName($domains)];
605
606 2
        $san = implode(",", array_map(function ($dns) {
607 2
            return "DNS:" . $dns;
608
        }, $domains));
609
        $tmpConf = tmpfile();
610
        if ($tmpConf === false) {
611
            //@codeCoverageIgnoreStart
612
            throw new RuntimeException('LEOrder::generateCSR failed to create tmp file');
613
            //@codeCoverageIgnoreEnd
614
        }
615
        $tmpConfMeta = stream_get_meta_data($tmpConf);
616
        $tmpConfPath = $tmpConfMeta["uri"];
617
618
        fwrite(
619 2
            $tmpConf,
620
            'HOME = .
621
			RANDFILE = $ENV::HOME/.rnd
622
			[ req ]
623 2
			default_bits = 4096
624 2
			default_keyfile = privkey.pem
625 2
			distinguished_name = req_distinguished_name
626 2
			req_extensions = v3_req
627
			[ req_distinguished_name ]
628
			countryName = Country Name (2 letter code)
629 2
			[ v3_req ]
630
			basicConstraints = CA:FALSE
631 2
			subjectAltName = ' . $san . '
632 2
			keyUsage = nonRepudiation, digitalSignature, keyEncipherment'
633
        );
634
635
        $privateKey = $this->loadCertificateKey();
636
        $csr = openssl_csr_new($dn, $privateKey, ['config' => $tmpConfPath, 'digest_alg' => 'sha256']);
637
        openssl_csr_export($csr, $csr);
638 2
        return $csr;
639
    }
640
641
    private function calcCommonName($domains)
642
    {
643
        if (in_array($this->basename, $domains)) {
644
            $CN = $this->basename;
645
        } elseif (in_array('*.' . $this->basename, $domains)) {
646
            $CN = '*.' . $this->basename;
647
        } else {
648
            $CN = $domains[0];
649
        }
650 2
        return $CN;
651
    }
652 2
653 2
    /**
654 2
     * Checks, for redundancy, whether all authorizations are valid, and finalizes the order. Updates this LetsEncrypt
655 2
     * Order instance with the new data.
656
     *
657 2
     * @param string $csr The Certificate Signing Request as a string. Can be a custom CSR. If empty, a CSR will
658 2
     *                    be generated with the generateCSR() function.
659 2
     *
660 2
     * @return boolean  Returns true if the finalize request was successful, false if not.
661
     */
662
    public function finalizeOrder($csr = '')
663 2
    {
664
        if ($this->status == 'pending') {
665 2
            if ($this->allAuthorizationsValid()) {
666 2
                if (empty($csr)) {
667 2
                    $csr = $this->generateCSR();
668 2
                }
669 2
                if (preg_match(
670
                    '~-----BEGIN\sCERTIFICATE\sREQUEST-----(.*)-----END\sCERTIFICATE\sREQUEST-----~s',
671 2
                    $csr,
672 2
                    $matches
673 2
                )
674 2
                ) {
675 2
                    $csr = $matches[1];
676 2
                }
677 2
                $csr = trim(LEFunctions::base64UrlSafeEncode(base64_decode($csr)));
678 2
                $sign = $this->connector->signRequestKid(
679 2
                    ['csr' => $csr],
680
                    $this->connector->accountURL,
681 2
                    $this->finalizeURL
682 2
                );
683
                $post = $this->connector->post($this->finalizeURL, $sign);
684 2
                if (strpos($post['header'], "200 OK") !== false) {
685
                    $this->status = $post['body']['status'];
686
                    $this->expires = $post['body']['expires'];
687
                    $this->identifiers = $post['body']['identifiers'];
688
                    $this->authorizationURLs = $post['body']['authorizations'];
689
                    $this->finalizeURL = $post['body']['finalize'];
690
                    if (array_key_exists('certificate', $post['body'])) {
0 ignored issues
show
Bug introduced by
It seems like $post['body'] can also be of type Psr\Http\Message\StreamInterface; however, parameter $search of array_key_exists() does only seem to accept array, maybe add an additional type check? ( Ignorable by Annotation )

If this is a false-positive, you can also ignore this issue in your code via the ignore-type  annotation

690
                    if (array_key_exists('certificate', /** @scrutinizer ignore-type */ $post['body'])) {
Loading history...
691
                        $this->certificateURL = $post['body']['certificate'];
692
                    }
693
                    $this->updateAuthorizations();
694
                    $this->log->info('Order for \'' . $this->basename . '\' finalized.');
695
696
                    return true;
697
                }
698
            } else {
699
                $this->log->warning(
700
                    'Not all authorizations are valid for \'' .
701
                    $this->basename . '\'. Cannot finalize order.'
702
                );
703
            }
704
        } else {
705
            $this->log->warning(
706
                'Order status for \'' . $this->basename .
707 2
                '\' is \'' . $this->status . '\'. Cannot finalize order.'
708
            );
709 2
        }
710
        return false;
711
    }
712
713
    /**
714
     * Gets whether the LetsEncrypt Order is finalized by checking whether the status is processing or valid. Keep in
715
     * mind, a certificate is not yet available when the status still is processing.
716
     *
717
     * @return boolean  Returns true if finalized, false if not.
718
     */
719
    public function isFinalized()
720 30
    {
721
        return ($this->status == 'processing' || $this->status == 'valid');
722 30
    }
723 30
724 28
    /**
725
     * Requests the certificate for this LetsEncrypt Order instance, after finalization. When the order status is still
726 28
     * 'processing', the order will be polled max four times with five seconds in between. If the status becomes 'valid'
727 28
     * in the meantime, the certificate will be requested. Else, the function returns false.
728 28
     *
729
     * @return boolean  Returns true if the certificate is stored successfully, false if the certificate could not be
730
     *                  retrieved or the status remained 'processing'.
731 30
     */
732 4
    public function getCertificate()
733 4
    {
734
        $polling = 0;
735 4
        while ($this->status == 'processing' && $polling < 4) {
736
            $this->log->info('Certificate for ' . $this->basename . ' being processed. Retrying in 5 seconds...');
737
738 26
            $this->sleep->for(5);
739 26
            $this->updateOrderData();
740 4
            $polling++;
741 4
        }
742 4
743
        if ($this->status != 'valid' || empty($this->certificateURL)) {
744 4
            $this->log->warning(
745
                'Order for ' . $this->basename . ' not valid. Cannot retrieve certificate.'
746
            );
747 22
            return false;
748
        }
749
750
        $sign = $this->connector->signRequestKid(
751 22
            null,
752
            $this->connector->accountURL,
753 22
            $this->certificateURL
754 18
        );
755
756 18
        $post = $this->connector->post($this->certificateURL, $sign);
757 18
        if (strpos($post['header'], "200 OK") === false) {
758 18
            $this->log->warning(
759
                'Invalid response for certificate request for \'' . $this->basename .
760 18
                '\'. Cannot save certificate.'
761 18
            );
762
            return false;
763 18
        }
764
765 18
        return $this->writeCertificates($post['body']);
766 18
    }
767
768
769 4
    private function writeCertificates($body)
770 4
    {
771
        if (preg_match_all('~(-----BEGIN\sCERTIFICATE-----[\s\S]+?-----END\sCERTIFICATE-----)~i', $body, $matches)) {
772
            $this->storage->setCertificate($this->basename, $matches[0][0]);
773
774
            $matchCount = count($matches[0]);
775
            if ($matchCount > 1) {
776
                $fullchain = $matches[0][0] . "\n";
777
778
                for ($i = 1; $i < $matchCount; $i++) {
779
                    $fullchain .= $matches[0][$i] . "\n";
780
                }
781
                $this->storage->setFullChainCertificate($this->basename, $fullchain);
782
            }
783 16
            $this->log->info("Certificate for {$this->basename} stored");
784
            return true;
785 16
        }
786 4
787 4
        $this->log->error("Received invalid certificate for {$this->basename}, cannot save");
788
        return false;
789
    }
790 12
791 12
    /**
792 4
     * Revokes the certificate in the current LetsEncrypt Order instance, if existent. Unlike stated in the ACME draft,
793 4
     * the certificate revoke request cannot be signed with the account private key, and will be signed with the
794
     * certificate private key.
795
     *
796 8
     * @param int $reason The reason to revoke the LetsEncrypt Order instance certificate. Possible reasons can be
797 8
     *                        found in section 5.3.1 of RFC5280.
798
     *
799 8
     * @return boolean  Returns true if the certificate was successfully revoked, false if not.
800 8
     */
801 8
    public function revokeCertificate($reason = 0)
802 8
    {
803 8
        if ($this->status != 'valid') {
804
            $this->log->warning("Order for {$this->basename} not valid, cannot revoke");
805
            return false;
806 8
        }
807 4
808 4
        $certificate = $this->storage->getCertificate($this->basename);
809
        if (empty($certificate)) {
810
            $this->log->warning("Certificate for {$this->basename} not found, cannot revoke");
811
            return false;
812
        }
813
814
        preg_match('~-----BEGIN\sCERTIFICATE-----(.*)-----END\sCERTIFICATE-----~s', $certificate, $matches);
815
        $certificate = trim(LEFunctions::base64UrlSafeEncode(base64_decode(trim($matches[1]))));
816
817
        $certificateKey = $this->storage->getPrivateKey($this->basename);
818
        $sign = $this->connector->signRequestJWK(
819
            ['certificate' => $certificate, 'reason' => $reason],
820
            $this->connector->revokeCert,
821
            $certificateKey
822
        );
823
        //4**/5** responses will throw an exception...
824
        $this->connector->post($this->connector->revokeCert, $sign);
825
        $this->log->info("Certificate for {$this->basename} successfully revoked");
826
        return true;
827
    }
828
}
829