Passed
Pull Request — master (#1)
by John
02:11
created

LEOrder::writeCertificates()   A

Complexity

Conditions 4
Paths 3

Size

Total Lines 20
Code Lines 12

Duplication

Lines 0
Ratio 0 %

Importance

Changes 0
Metric Value
cc 4
eloc 12
nc 3
nop 1
dl 0
loc 20
rs 9.8666
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
    public function __construct(
82
        LEConnector $connector,
83
        CertificateStorageInterface $storage,
84
        LoggerInterface $log,
85
        DNSValidatorInterface $dns,
86
        Sleep $sleep
87
    ) {
88
89
        $this->connector = $connector;
90
        $this->log = $log;
91
        $this->dns = $dns;
92
        $this->sleep = $sleep;
93
        $this->storage = $storage;
94
    }
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
    public function loadOrder($basename, array $domains, $keyType, $notBefore, $notAfter)
114
    {
115
        $this->basename = $basename;
116
117
        $this->initialiseKeyTypeAndSize($keyType ?? 'rsa-4096');
118
119
        if ($this->loadExistingOrder($domains)) {
120
            $this->updateAuthorizations();
121
        } else {
122
            $this->createOrder($domains, $notBefore, $notAfter);
123
        }
124
    }
125
126
    private function loadExistingOrder($domains)
127
    {
128
        $orderUrl = $this->storage->getMetadata($this->basename.'.order.url');
129
        $publicKey = $this->storage->getPublicKey($this->basename);
130
        $privateKey = $this->storage->getPrivateKey($this->basename);
131
132
        //anything to load?
133
        if (empty($orderUrl) || empty($publicKey) || empty($privateKey)) {
134
            $this->log->info("No order found for {$this->basename}. Creating new order.");
135
            return false;
136
        }
137
138
        //valid URL?
139
        $this->orderURL = $orderUrl;
140
        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
        $get = $this->connector->get($this->orderURL);
150
        if ($get['status'] !== 200) {
151
            //@codeCoverageIgnoreStart
152
            $this->log->warning("Order for {$this->basename} could not be loaded. Creating new order.");
153
            $this->deleteOrderFiles();
154
            return false;
155
            //@codeCoverageIgnoreEnd
156
        }
157
158
        //ensure the order is still valid
159
        if ($get['body']['status'] === 'invalid') {
160
            $this->log->warning("Order for {$this->basename} is 'invalid', unable to authorize. Creating new order.");
161
            $this->deleteOrderFiles();
162
            return false;
163
        }
164
165
        //ensure retrieved order matches our domains
166
        $orderdomains = array_map(function ($ident) {
167
            return $ident['value'];
168
        }, $get['body']['identifiers']);
169
        $diff = array_merge(array_diff($orderdomains, $domains), array_diff($domains, $orderdomains));
170
        if (!empty($diff)) {
171
            $this->log->warning('Domains do not match order data. Deleting and creating new order.');
172
            $this->deleteOrderFiles();
173
            return false;
174
        }
175
176
        //the order is good
177
        $this->status = $get['body']['status'];
178
        $this->expires = $get['body']['expires'];
179
        $this->identifiers = $get['body']['identifiers'];
180
        $this->authorizationURLs = $get['body']['authorizations'];
181
        $this->finalizeURL = $get['body']['finalize'];
182
        if (array_key_exists('certificate', $get['body'])) {
0 ignored issues
show
Bug introduced by
It seems like $get['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

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

272
        if (array_key_exists('certificate', /** @scrutinizer ignore-type */ $post['body'])) {
Loading history...
273
            $this->certificateURL = $post['body']['certificate'];
274
        }
275
        $this->updateAuthorizations();
276
277
        $this->log->info('Created order for ' . $this->basename);
278
    }
279
280
    private function generateKeys()
281
    {
282
        if ($this->keyType == "rsa") {
283
            $key = LEFunctions::RSAgenerateKeys($this->keySize);
284
        } else {
285
            $key = LEFunctions::ECgenerateKeys($this->keySize);
286
        }
287
288
        $this->storage->setPublicKey($this->basename, $key['public']);
289
        $this->storage->setPrivateKey($this->basename, $key['private']);
290
    }
291
292
    /**
293
     * Fetches the latest data concerning this LetsEncrypt Order instance and fills this instance with the new data.
294
     */
295
    private function updateOrderData()
296
    {
297
        $get = $this->connector->get($this->orderURL);
298
        if (strpos($get['header'], "200 OK") !== false) {
299
            $this->status = $get['body']['status'];
300
            $this->expires = $get['body']['expires'];
301
            $this->identifiers = $get['body']['identifiers'];
302
            $this->authorizationURLs = $get['body']['authorizations'];
303
            $this->finalizeURL = $get['body']['finalize'];
304
            if (array_key_exists('certificate', $get['body'])) {
0 ignored issues
show
Bug introduced by
It seems like $get['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

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

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