Completed
Branch master (793070)
by John
01:53
created

LEOrder::deleteOrderFiles()   A

Complexity

Conditions 1
Paths 1

Size

Total Lines 7
Code Lines 5

Duplication

Lines 0
Ratio 0 %

Code Coverage

Tests 6
CRAP Score 1

Importance

Changes 0
Metric Value
cc 1
eloc 5
nc 1
nop 0
dl 0
loc 7
ccs 6
cts 6
cp 1
crap 1
rs 10
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
    /**
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 16
            $this->connector->accountURL,
152 16
            $this->orderURL
153
        );
154
155 16
        $post = $this->connector->post($this->orderURL, $sign);
156 16
        if ($post['status'] !== 200) {
157
            //@codeCoverageIgnoreStart
158
            $this->log->warning("Order for {$this->basename} could not be loaded. Creating new order.");
159
            $this->deleteOrderFiles();
160
            return false;
161
            //@codeCoverageIgnoreEnd
162
        }
163
164
        //ensure the order is still valid
165 16
        if ($post['body']['status'] === 'invalid') {
166 4
            $this->log->warning("Order for {$this->basename} is 'invalid', unable to authorize. Creating new order.");
167 4
            $this->deleteOrderFiles();
168 4
            return false;
169
        }
170
171
        //ensure retrieved order matches our domains
172 12
        $orderdomains = array_map(function ($ident) {
173 12
            return $ident['value'];
174 12
        }, $post['body']['identifiers']);
175 12
        $diff = array_merge(array_diff($orderdomains, $domains), array_diff($domains, $orderdomains));
176 12
        if (!empty($diff)) {
177 4
            $this->log->warning('Domains do not match order data. Deleting and creating new order.');
178 4
            $this->deleteOrderFiles();
179 4
            return false;
180
        }
181
182
        //the order is good
183 8
        $this->status = $post['body']['status'];
184 8
        $this->expires = $post['body']['expires'];
185 8
        $this->identifiers = $post['body']['identifiers'];
186 8
        $this->authorizationURLs = $post['body']['authorizations'];
187 8
        $this->finalizeURL = $post['body']['finalize'];
188 8
        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 4
            $this->certificateURL = $post['body']['certificate'];
190
        }
191
192 8
        return true;
193
    }
194
195 8
    private function deleteOrderFiles()
196
    {
197 8
        $this->storage->setPrivateKey($this->basename, null);
198 8
        $this->storage->setPublicKey($this->basename, null);
199 8
        $this->storage->setCertificate($this->basename, null);
200 8
        $this->storage->setFullChainCertificate($this->basename, null);
201 8
        $this->storage->setMetadata($this->basename.'.order.url', null);
202 8
    }
203
204 72
    private function initialiseKeyTypeAndSize($keyType)
205
    {
206 72
        if ($keyType == 'rsa') {
207 20
            $this->keyType = 'rsa';
208 20
            $this->keySize = 4096;
209 52
        } elseif ($keyType == 'ec') {
210 32
            $this->keyType = 'ec';
211 32
            $this->keySize = 256;
212
        } else {
213 20
            preg_match_all('/^(rsa|ec)\-([0-9]{3,4})$/', $keyType, $keyTypeParts, PREG_SET_ORDER, 0);
214
215 20
            if (!empty($keyTypeParts)) {
216 16
                $this->keyType = $keyTypeParts[0][1];
217 16
                $this->keySize = intval($keyTypeParts[0][2]);
218
            } else {
219 4
                throw new LogicException('Key type \'' . $keyType . '\' not supported.');
220
            }
221
        }
222 68
    }
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
     * @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
     *                          at which the certificate becomes valid.
231
     * @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
     */
234 68
    private function createOrder($domains, $notBefore, $notAfter)
235
    {
236 68
        if (!preg_match('~(\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}Z|^$)~', $notBefore) ||
237 68
            !preg_match('~(\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}Z|^$)~', $notAfter)
238
        ) {
239 4
            throw new LogicException("notBefore and notAfter must be blank or iso-8601 datestamp");
240
        }
241
242 64
        $dns = [];
243 64
        foreach ($domains as $domain) {
244 64
            if (preg_match_all('~(\*\.)~', $domain) > 1) {
245 4
                throw new LogicException('Cannot create orders with multiple wildcards in one domain.');
246
            }
247 60
            $dns[] = ['type' => 'dns', 'value' => $domain];
248
        }
249 60
        $payload = ["identifiers" => $dns, 'notBefore' => $notBefore, 'notAfter' => $notAfter];
250 60
        $sign = $this->connector->signRequestKid(
251 60
            $payload,
252 60
            $this->connector->accountURL,
253 60
            $this->connector->newOrder
254
        );
255 60
        $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
            //@codeCoverageIgnoreStart
264
            throw new RuntimeException('New-order returned invalid response.');
265
            //@codeCoverageIgnoreEnd
266
        }
267
268 60
        $this->orderURL = trim($matches[1]);
269 60
        $this->storage->setMetadata($this->basename.'.order.url', $this->orderURL);
270
271 60
        $this->generateKeys();
272
273 60
        $this->status = $post['body']['status'];
274 60
        $this->expires = $post['body']['expires'];
275 60
        $this->identifiers = $post['body']['identifiers'];
276 60
        $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 40
            $this->certificateURL = $post['body']['certificate'];
280
        }
281 60
        $this->updateAuthorizations();
282
283 60
        $this->log->info('Created order for ' . $this->basename);
284 60
    }
285
286 60
    private function generateKeys()
287
    {
288 60
        if ($this->keyType == "rsa") {
289 32
            $key = LEFunctions::RSAgenerateKeys($this->keySize);
290
        } else {
291 28
            $key = LEFunctions::ECgenerateKeys($this->keySize);
292
        }
293
294 60
        $this->storage->setPublicKey($this->basename, $key['public']);
295 60
        $this->storage->setPrivateKey($this->basename, $key['private']);
296 60
    }
297
298
    /**
299
     * Fetches the latest data concerning this LetsEncrypt Order instance and fills this instance with the new data.
300
     */
301 28
    private function updateOrderData()
302
    {
303 28
        $sign = $this->connector->signRequestKid(
304 28
            null,
305 28
            $this->connector->accountURL,
306 28
            $this->orderURL
307
        );
308
309 28
        $post = $this->connector->post($this->orderURL, $sign);
310 28
        if (strpos($post['header'], "200 OK") !== false) {
311 28
            $this->status = $post['body']['status'];
312 28
            $this->expires = $post['body']['expires'];
313 28
            $this->identifiers = $post['body']['identifiers'];
314 28
            $this->authorizationURLs = $post['body']['authorizations'];
315 28
            $this->finalizeURL = $post['body']['finalize'];
316 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 $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 28
                $this->certificateURL = $post['body']['certificate'];
318
            }
319 28
            $this->updateAuthorizations();
320
        } else {
321
            //@codeCoverageIgnoreStart
322
            $this->log->error("Failed to fetch order for {$this->basename}");
323
            //@codeCoverageIgnoreEnd
324
        }
325 28
    }
326
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
     */
331 60
    private function updateAuthorizations()
332
    {
333 60
        $this->authorizations = [];
334 60
        foreach ($this->authorizationURLs as $authURL) {
335 56
            if (filter_var($authURL, FILTER_VALIDATE_URL)) {
336 56
                $auth = new LEAuthorization($this->connector, $this->log, $authURL);
337 56
                if ($auth != false) {
338 56
                    $this->authorizations[] = $auth;
339
                }
340
            }
341
        }
342 60
    }
343
344
    /**
345
     * Walks all LetsEncrypt Authorization instances and returns whether they are all valid (verified).
346
     *
347
     * @return boolean  Returns true if all authorizations are valid (verified), returns false if not.
348
     */
349 6
    public function allAuthorizationsValid()
350
    {
351 6
        if (count($this->authorizations) > 0) {
352 2
            foreach ($this->authorizations as $auth) {
353 2
                if ($auth->status != 'valid') {
354 2
                    return false;
355
                }
356
            }
357 2
            return true;
358
        }
359 4
        return false;
360
    }
361
362 6
    private function loadAccountKey()
363
    {
364 6
        $keydata = $this->storage->getAccountPrivateKey();
365 6
        $privateKey = openssl_pkey_get_private($keydata);
366 6
        if ($privateKey === false) {
367
            //@codeCoverageIgnoreStart
368
            throw new RuntimeException("Failed load account key");
369
            //@codeCoverageIgnoreEnd
370
        }
371 6
        return $privateKey;
372
    }
373
374
375 2
    private function loadCertificateKey()
376
    {
377 2
        $keydata = $this->storage->getPrivateKey($this->basename);
378 2
        $privateKey = openssl_pkey_get_private($keydata);
379 2
        if ($privateKey === false) {
380
            //@codeCoverageIgnoreStart
381
            throw new RuntimeException("Failed load certificate key");
382
            //@codeCoverageIgnoreEnd
383
        }
384 2
        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
     *                     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
     *                     accepts LEOrder::CHALLENGE_TYPE_DNS.
395
     *
396
     * @return array|bool Returns an array with verification data if successful, false if not pending LetsEncrypt
397
     *                  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
     *                  For LEOrder::CHALLENGE_TYPE_DNS, the array contains 'DNSDigest', which is the content for the
401
     *                  necessary DNS TXT entry.
402
     */
403
404 6
    public function getPendingAuthorizations($type)
405
    {
406 6
        $authorizations = [];
407
408 6
        $privateKey = $this->loadAccountKey();
409 6
        $details = openssl_pkey_get_details($privateKey);
410
411
        $header = [
412 6
            "e" => LEFunctions::base64UrlSafeEncode($details["rsa"]["e"]),
413 6
            "kty" => "RSA",
414 6
            "n" => LEFunctions::base64UrlSafeEncode($details["rsa"]["n"])
415
416
        ];
417 6
        $digest = LEFunctions::base64UrlSafeEncode(hash('sha256', json_encode($header), true));
418
419 6
        foreach ($this->authorizations as $auth) {
420 6
            if ($auth->status == 'pending') {
421 6
                $challenge = $auth->getChallenge($type);
422 6
                if ($challenge['status'] == 'pending') {
423 6
                    $keyAuthorization = $challenge['token'] . '.' . $digest;
424 6
                    switch (strtolower($type)) {
425 3
                        case LEOrder::CHALLENGE_TYPE_HTTP:
426 4
                            $authorizations[] = [
427 4
                                'type' => LEOrder::CHALLENGE_TYPE_HTTP,
428 4
                                'identifier' => $auth->identifier['value'],
429 4
                                'filename' => $challenge['token'],
430 4
                                'content' => $keyAuthorization
431
                            ];
432 4
                            break;
433 1
                        case LEOrder::CHALLENGE_TYPE_DNS:
434 2
                            $DNSDigest = LEFunctions::base64UrlSafeEncode(
435 2
                                hash('sha256', $keyAuthorization, true)
436
                            );
437 2
                            $authorizations[] = [
438 2
                                'type' => LEOrder::CHALLENGE_TYPE_DNS,
439 2
                                'identifier' => $auth->identifier['value'],
440 2
                                'DNSDigest' => $DNSDigest
441
                            ];
442 4
                            break;
443
                    }
444
                }
445
            }
446
        }
447
448 6
        return count($authorizations) > 0 ? $authorizations : false;
449
    }
450
451
    /**
452
     * Sends a verification request for a given $identifier and $type. The function itself checks whether the
453
     * verification is valid before making the request.
454
     * Updates the LetsEncrypt Authorization instances after a successful verification.
455
     *
456
     * @param string $identifier The domain name to verify.
457
     * @param int $type The type of verification. Supporting LEOrder::CHALLENGE_TYPE_HTTP and
458
     *                           LEOrder::CHALLENGE_TYPE_DNS.
459
     *
460
     * @return boolean  Returns true when the verification request was successful, false if not.
461
     */
462 2
    public function verifyPendingOrderAuthorization($identifier, $type)
463
    {
464 2
        $privateKey = $this->loadAccountKey();
465 2
        $details = openssl_pkey_get_details($privateKey);
466
467
        $header = [
468 2
            "e" => LEFunctions::base64UrlSafeEncode($details["rsa"]["e"]),
469 2
            "kty" => "RSA",
470 2
            "n" => LEFunctions::base64UrlSafeEncode($details["rsa"]["n"])
471
        ];
472 2
        $digest = LEFunctions::base64UrlSafeEncode(hash('sha256', json_encode($header), true));
473
474 2
        foreach ($this->authorizations as $auth) {
475 2
            if ($auth->identifier['value'] == $identifier) {
476 2
                if ($auth->status == 'pending') {
477 2
                    $challenge = $auth->getChallenge($type);
478 2
                    if ($challenge['status'] == 'pending') {
479 2
                        $keyAuthorization = $challenge['token'] . '.' . $digest;
480 1
                        switch ($type) {
481 1
                            case LEOrder::CHALLENGE_TYPE_HTTP:
482
                                return $this->verifyHTTPChallenge($identifier, $challenge, $keyAuthorization, $auth);
483 1
                            case LEOrder::CHALLENGE_TYPE_DNS:
484 2
                                return $this->verifyDNSChallenge($identifier, $challenge, $keyAuthorization, $auth);
485
                        }
486
                    }
487
                }
488
            }
489
        }
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
    }
496
497 2
    private function verifyDNSChallenge($identifier, array $challenge, $keyAuthorization, LEAuthorization $auth)
498
    {
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
        //ask LE to check
507 2
        $sign = $this->connector->signRequestKid(
508 2
            ['keyAuthorization' => $keyAuthorization],
509 2
            $this->connector->accountURL,
510 2
            $challenge['url']
511
        );
512 2
        $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 2
        while ($auth->status == 'pending') {
519 2
            $this->log->notice("DNS challenge for $identifier valid - waiting for confirmation");
520 2
            $this->sleep->for(1);
521 2
            $auth->updateData();
522
        }
523 2
        $this->log->notice("DNS challenge for $identifier validated");
524
525 2
        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
    */
589
590
    /**
591
     * Generates a Certificate Signing Request for the identifiers in the current LetsEncrypt Order instance.
592
     * 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
     *
595
     * @return string   Returns the generated CSR as string, unprepared for LetsEncrypt. Preparation for the request
596
     *                  happens in finalizeOrder()
597
     */
598
    private function generateCSR()
599
    {
600 2
        $domains = array_map(function ($dns) {
601 2
            return $dns['value'];
602 2
        }, $this->identifiers);
603
604 2
        $dn = ["commonName" => $this->calcCommonName($domains)];
605
606 2
        $san = implode(",", array_map(function ($dns) {
607 2
            return "DNS:" . $dns;
608 2
        }, $domains));
609 2
        $tmpConf = tmpfile();
610 2
        if ($tmpConf === false) {
611
            //@codeCoverageIgnoreStart
612
            throw new RuntimeException('LEOrder::generateCSR failed to create tmp file');
613
            //@codeCoverageIgnoreEnd
614
        }
615 2
        $tmpConfMeta = stream_get_meta_data($tmpConf);
616 2
        $tmpConfPath = $tmpConfMeta["uri"];
617
618 2
        fwrite(
619 2
            $tmpConf,
620
            'HOME = .
621
			RANDFILE = $ENV::HOME/.rnd
622
			[ req ]
623
			default_bits = 4096
624
			default_keyfile = privkey.pem
625
			distinguished_name = req_distinguished_name
626
			req_extensions = v3_req
627
			[ req_distinguished_name ]
628
			countryName = Country Name (2 letter code)
629
			[ v3_req ]
630
			basicConstraints = CA:FALSE
631 2
			subjectAltName = ' . $san . '
632
			keyUsage = nonRepudiation, digitalSignature, keyEncipherment'
633
        );
634
635 2
        $privateKey = $this->loadCertificateKey();
636 2
        $csr = openssl_csr_new($dn, $privateKey, ['config' => $tmpConfPath, 'digest_alg' => 'sha256']);
637 2
        openssl_csr_export($csr, $csr);
638 2
        return $csr;
639
    }
640
641 2
    private function calcCommonName($domains)
642
    {
643 2
        if (in_array($this->basename, $domains)) {
644 2
            $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
653
    /**
654
     * Checks, for redundancy, whether all authorizations are valid, and finalizes the order. Updates this LetsEncrypt
655
     * Order instance with the new data.
656
     *
657
     * @param string $csr The Certificate Signing Request as a string. Can be a custom CSR. If empty, a CSR will
658
     *                    be generated with the generateCSR() function.
659
     *
660
     * @return boolean  Returns true if the finalize request was successful, false if not.
661
     */
662 2
    public function finalizeOrder($csr = '')
663
    {
664 2
        if ($this->status == 'pending') {
665 2
            if ($this->allAuthorizationsValid()) {
666 2
                if (empty($csr)) {
667 2
                    $csr = $this->generateCSR();
668
                }
669 2
                if (preg_match(
670 2
                    '~-----BEGIN\sCERTIFICATE\sREQUEST-----(.*)-----END\sCERTIFICATE\sREQUEST-----~s',
671 2
                    $csr,
672 2
                    $matches
673
                )
674
                ) {
675 2
                    $csr = $matches[1];
676
                }
677 2
                $csr = trim(LEFunctions::base64UrlSafeEncode(base64_decode($csr)));
678 2
                $sign = $this->connector->signRequestKid(
679 2
                    ['csr' => $csr],
680 2
                    $this->connector->accountURL,
681 2
                    $this->finalizeURL
682
                );
683 2
                $post = $this->connector->post($this->finalizeURL, $sign);
684 2
                if (strpos($post['header'], "200 OK") !== false) {
685 2
                    $this->status = $post['body']['status'];
686 2
                    $this->expires = $post['body']['expires'];
687 2
                    $this->identifiers = $post['body']['identifiers'];
688 2
                    $this->authorizationURLs = $post['body']['authorizations'];
689 2
                    $this->finalizeURL = $post['body']['finalize'];
690 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 $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 2
                        $this->certificateURL = $post['body']['certificate'];
692
                    }
693 2
                    $this->updateAuthorizations();
694 2
                    $this->log->info('Order for \'' . $this->basename . '\' finalized.');
695
696 2
                    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
                '\' is \'' . $this->status . '\'. Cannot finalize order.'
708
            );
709
        }
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 2
    public function isFinalized()
720
    {
721 2
        return ($this->status == 'processing' || $this->status == 'valid');
722
    }
723
724
    /**
725
     * Requests the certificate for this LetsEncrypt Order instance, after finalization. When the order status is still
726
     * 'processing', the order will be polled max four times with five seconds in between. If the status becomes 'valid'
727
     * in the meantime, the certificate will be requested. Else, the function returns false.
728
     *
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
     */
732 30
    public function getCertificate()
733
    {
734 30
        $polling = 0;
735 30
        while ($this->status == 'processing' && $polling < 4) {
736 28
            $this->log->info('Certificate for ' . $this->basename . ' being processed. Retrying in 5 seconds...');
737
738 28
            $this->sleep->for(5);
739 28
            $this->updateOrderData();
740 28
            $polling++;
741
        }
742
743 30
        if ($this->status != 'valid' || empty($this->certificateURL)) {
744 4
            $this->log->warning(
745 4
                'Order for ' . $this->basename . ' not valid. Cannot retrieve certificate.'
746
            );
747 4
            return false;
748
        }
749
750 26
        $sign = $this->connector->signRequestKid(
751 26
            null,
752 26
            $this->connector->accountURL,
753 26
            $this->certificateURL
754
        );
755
756 26
        $post = $this->connector->post($this->certificateURL, $sign);
757 26
        if (strpos($post['header'], "200 OK") === false) {
758 4
            $this->log->warning(
759 4
                'Invalid response for certificate request for \'' . $this->basename .
760 4
                '\'. Cannot save certificate.'
761
            );
762 4
            return false;
763
        }
764
765 22
        return $this->writeCertificates($post['body']);
766
    }
767
768 22
    private function writeCertificates($body)
769
    {
770 22
        if (preg_match_all('~(-----BEGIN\sCERTIFICATE-----[\s\S]+?-----END\sCERTIFICATE-----)~i', $body, $matches)) {
771 18
            $this->storage->setCertificate($this->basename, $matches[0][0]);
772
773 18
            $matchCount = count($matches[0]);
774 18
            if ($matchCount > 1) {
775 18
                $fullchain = $matches[0][0] . "\n";
776
777 18
                for ($i = 1; $i < $matchCount; $i++) {
778 18
                    $fullchain .= $matches[0][$i] . "\n";
779
                }
780 18
                $this->storage->setFullChainCertificate($this->basename, $fullchain);
781
            }
782 18
            $this->log->info("Certificate for {$this->basename} stored");
783 18
            return true;
784
        }
785
786 4
        $this->log->error("Received invalid certificate for {$this->basename}, cannot save");
787 4
        return false;
788
    }
789
790
    /**
791
     * Revokes the certificate in the current LetsEncrypt Order instance, if existent. Unlike stated in the ACME draft,
792
     * the certificate revoke request cannot be signed with the account private key, and will be signed with the
793
     * certificate private key.
794
     *
795
     * @param int $reason The reason to revoke the LetsEncrypt Order instance certificate. Possible reasons can be
796
     *                        found in section 5.3.1 of RFC5280.
797
     *
798
     * @return boolean  Returns true if the certificate was successfully revoked, false if not.
799
     */
800 16
    public function revokeCertificate($reason = 0)
801
    {
802 16
        if ($this->status != 'valid') {
803 4
            $this->log->warning("Order for {$this->basename} not valid, cannot revoke");
804 4
            return false;
805
        }
806
807 12
        $certificate = $this->storage->getCertificate($this->basename);
808 12
        if (empty($certificate)) {
809 4
            $this->log->warning("Certificate for {$this->basename} not found, cannot revoke");
810 4
            return false;
811
        }
812
813 8
        preg_match('~-----BEGIN\sCERTIFICATE-----(.*)-----END\sCERTIFICATE-----~s', $certificate, $matches);
814 8
        $certificate = trim(LEFunctions::base64UrlSafeEncode(base64_decode(trim($matches[1]))));
815
816 8
        $certificateKey = $this->storage->getPrivateKey($this->basename);
817 8
        $sign = $this->connector->signRequestJWK(
818 8
            ['certificate' => $certificate, 'reason' => $reason],
819 8
            $this->connector->revokeCert,
820 8
            $certificateKey
821
        );
822
        //4**/5** responses will throw an exception...
823 8
        $this->connector->post($this->connector->revokeCert, $sign);
824 4
        $this->log->info("Certificate for {$this->basename} successfully revoked");
825 4
        return true;
826
    }
827
}
828