LEOrder::createOrder()   B
last analyzed

Complexity

Conditions 8
Paths 10

Size

Total Lines 50
Code Lines 30

Duplication

Lines 0
Ratio 0 %

Code Coverage

Tests 29
CRAP Score 8

Importance

Changes 0
Metric Value
cc 8
eloc 30
nc 10
nop 3
dl 0
loc 50
ccs 29
cts 29
cp 1
crap 8
rs 8.1954
c 0
b 0
f 0
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
    /** @var AccountStorageInterface */
71
    private $accountStorage;
72
73
    /**
74
     * Initiates the LetsEncrypt Order class. If the base name is found in the $keysDir directory, the order data is
75
     * requested. If no order was found locally, if the request is invalid or when there is a change in domain names, a
76
     * new order is created.
77
     *
78
     * @param LEConnector $connector The LetsEncrypt Connector instance to use for HTTP requests.
79
     * @param CertificateStorageInterface $storage
80
     * @param AccountStorageInterface $accountStorage
81
     * @param LoggerInterface $log PSR-3 compatible logger
82
     * @param DNSValidatorInterface $dns DNS challenge checking service
83
     * @param Sleep $sleep Sleep service for polling
84
     */
85 72
    public function __construct(
86
        LEConnector $connector,
87
        CertificateStorageInterface $storage,
88
        AccountStorageInterface $accountStorage,
89
        LoggerInterface $log,
90
        DNSValidatorInterface $dns,
91
        Sleep $sleep
92
    ) {
93
94 72
        $this->connector = $connector;
95 72
        $this->log = $log;
96 72
        $this->dns = $dns;
97 72
        $this->sleep = $sleep;
98 72
        $this->storage = $storage;
99 72
        $this->accountStorage = $accountStorage;
100 72
    }
101
102
    /**
103
     * Loads or updates an order. If the base name is found in the $keysDir directory, the order data is
104
     * requested. If no order was found locally, if the request is invalid or when there is a change in domain names, a
105
     * new order is created.
106
     *
107
     * @param string $basename The base name for the order. Preferable the top domain (example.org).
108
     *                                         Will be the directory in which the keys are stored. Used for the
109
     *                                         CommonName in the certificate as well.
110
     * @param array $domains The array of strings containing the domain names on the certificate.
111
     * @param string $keyType Type of the key we want to use for certificate. Can be provided in
112
     *                                         ALGO-SIZE format (ex. rsa-4096 or ec-256) or simply "rsa" and "ec"
113
     *                                         (using default sizes)
114
     * @param string $notBefore A date string formatted like 0000-00-00T00:00:00Z (yyyy-mm-dd hh:mm:ss)
115
     *                                         at which the certificate becomes valid.
116
     * @param string $notAfter A date string formatted like 0000-00-00T00:00:00Z (yyyy-mm-dd hh:mm:ss)
117
     *                                         until which the certificate is valid.
118
     */
119 72
    public function loadOrder($basename, array $domains, $keyType, $notBefore, $notAfter)
120
    {
121 72
        $this->basename = $basename;
122
123 72
        $this->initialiseKeyTypeAndSize($keyType ?? 'rsa-4096');
124
125 68
        if ($this->loadExistingOrder($domains)) {
126
            $this->updateAuthorizations();
127
        } else {
128 68
            $this->createOrder($domains, $notBefore, $notAfter);
129
        }
130 60
    }
131
132 68
    private function loadExistingOrder($domains)
133
    {
134 68
        $orderUrl = $this->storage->getMetadata($this->basename.'.order.url');
135 68
        $publicKey = $this->storage->getPublicKey($this->basename);
136 68
        $privateKey = $this->storage->getPrivateKey($this->basename);
137
138
        //anything to load?
139 68
        if (empty($orderUrl) || empty($publicKey) || empty($privateKey)) {
140 68
            $this->log->info("No order found for {$this->basename}. Creating new order.");
141 68
            return false;
142
        }
143
144
        //valid URL?
145
        $this->orderURL = $orderUrl;
146
        if (!filter_var($this->orderURL, FILTER_VALIDATE_URL)) {
147
            //@codeCoverageIgnoreStart
148
            $this->log->warning("Order for {$this->basename} has invalid URL. Creating new order.");
149
            $this->deleteOrderFiles();
150
            return false;
151
            //@codeCoverageIgnoreEnd
152
        }
153
154
        //retrieve the order
155
        $sign = $this->connector->signRequestKid(
156
            null,
157
            $this->connector->accountURL,
158
            $this->orderURL
159
        );
160
161
        $post = $this->connector->post($this->orderURL, $sign);
162
        if ($post['status'] !== 200) {
163
            //@codeCoverageIgnoreStart
164
            $this->log->warning("Order for {$this->basename} could not be loaded. Creating new order.");
165
            $this->deleteOrderFiles();
166
            return false;
167
            //@codeCoverageIgnoreEnd
168
        }
169
170
        //ensure the order is still valid
171
        if ($post['body']['status'] === 'invalid') {
172
            $this->log->warning("Order for {$this->basename} is 'invalid', unable to authorize. Creating new order.");
173
            $this->deleteOrderFiles();
174
            return false;
175
        }
176
177
        //ensure retrieved order matches our domains
178
        $orderdomains = array_map(function ($ident) {
179
            return $ident['value'];
180
        }, $post['body']['identifiers']);
181
        $diff = array_merge(array_diff($orderdomains, $domains), array_diff($domains, $orderdomains));
182
        if (!empty($diff)) {
183
            $this->log->warning('Domains do not match order data. Deleting and creating new order.');
184
            $this->deleteOrderFiles();
185
            return false;
186
        }
187
188
        //the order is good
189
        $this->status = $post['body']['status'];
190
        $this->expires = $post['body']['expires'];
191
        $this->identifiers = $post['body']['identifiers'];
192
        $this->authorizationURLs = $post['body']['authorizations'];
193
        $this->finalizeURL = $post['body']['finalize'];
194
        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 $array of array_key_exists() does only seem to accept ArrayObject|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

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

284
        if (array_key_exists('certificate', /** @scrutinizer ignore-type */ $post['body'])) {
Loading history...
285 40
            $this->certificateURL = $post['body']['certificate'];
286
        }
287 60
        $this->updateAuthorizations();
288
289 60
        $this->log->info('Created order for ' . $this->basename);
290 60
    }
291
292 60
    private function generateKeys()
293
    {
294 60
        if ($this->keyType == "rsa") {
295 32
            $key = LEFunctions::RSAgenerateKeys($this->keySize);
296
        } else {
297 28
            $key = LEFunctions::ECgenerateKeys($this->keySize);
298
        }
299
300 60
        $this->storage->setPublicKey($this->basename, $key['public']);
301 60
        $this->storage->setPrivateKey($this->basename, $key['private']);
302 60
    }
303
304
    /**
305
     * Fetches the latest data concerning this LetsEncrypt Order instance and fills this instance with the new data.
306
     */
307 28
    private function updateOrderData()
308
    {
309 28
        $sign = $this->connector->signRequestKid(
310 28
            null,
311 28
            $this->connector->accountURL,
312 28
            $this->orderURL
313
        );
314
315 28
        $post = $this->connector->post($this->orderURL, $sign);
316 28
        if (strpos($post['header'], "200 OK") !== false) {
317 28
            $this->status = $post['body']['status'];
318 28
            $this->expires = $post['body']['expires'];
319 28
            $this->identifiers = $post['body']['identifiers'];
320 28
            $this->authorizationURLs = $post['body']['authorizations'];
321 28
            $this->finalizeURL = $post['body']['finalize'];
322 28
            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 $array of array_key_exists() does only seem to accept ArrayObject|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

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

642
        $csr = openssl_csr_new($dn, /** @scrutinizer ignore-type */ $privateKey, ['config' => $tmpConfPath, 'digest_alg' => 'sha256']);
Loading history...
643 2
        openssl_csr_export($csr, $csr);
0 ignored issues
show
Bug introduced by
$csr of type OpenSSLCertificateSigningRequest|resource is incompatible with the type string expected by parameter $output of openssl_csr_export(). ( Ignorable by Annotation )

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

643
        openssl_csr_export($csr, /** @scrutinizer ignore-type */ $csr);
Loading history...
644 2
        return $csr;
645
    }
646
647 2
    private function calcCommonName($domains)
648
    {
649 2
        if (in_array($this->basename, $domains)) {
650 2
            $CN = $this->basename;
651
        } elseif (in_array('*.' . $this->basename, $domains)) {
652
            $CN = '*.' . $this->basename;
653
        } else {
654
            $CN = $domains[0];
655
        }
656 2
        return $CN;
657
    }
658
659
    /**
660
     * Checks, for redundancy, whether all authorizations are valid, and finalizes the order. Updates this LetsEncrypt
661
     * Order instance with the new data.
662
     *
663
     * @param string $csr The Certificate Signing Request as a string. Can be a custom CSR. If empty, a CSR will
664
     *                    be generated with the generateCSR() function.
665
     *
666
     * @return boolean  Returns true if the finalize request was successful, false if not.
667
     */
668 2
    public function finalizeOrder($csr = '')
669
    {
670 2
        if ($this->status == 'pending' || $this->status == 'ready') {
671 2
            if ($this->allAuthorizationsValid()) {
672 2
                if (empty($csr)) {
673 2
                    $csr = $this->generateCSR();
674
                }
675 2
                if (preg_match(
676 2
                    '~-----BEGIN\sCERTIFICATE\sREQUEST-----(.*)-----END\sCERTIFICATE\sREQUEST-----~s',
677 2
                    $csr,
678 2
                    $matches
679
                )
680
                ) {
681 2
                    $csr = $matches[1];
682
                }
683 2
                $csr = trim(LEFunctions::base64UrlSafeEncode(base64_decode($csr)));
684 2
                $sign = $this->connector->signRequestKid(
685 2
                    ['csr' => $csr],
686 2
                    $this->connector->accountURL,
687 2
                    $this->finalizeURL
688
                );
689 2
                $post = $this->connector->post($this->finalizeURL, $sign);
690 2
                if (strpos($post['header'], "200 OK") !== false) {
691 2
                    $this->status = $post['body']['status'];
692 2
                    $this->expires = $post['body']['expires'];
693 2
                    $this->identifiers = $post['body']['identifiers'];
694 2
                    $this->authorizationURLs = $post['body']['authorizations'];
695 2
                    $this->finalizeURL = $post['body']['finalize'];
696 2
                    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 $array of array_key_exists() does only seem to accept ArrayObject|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

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