Completed
Pull Request — master (#2)
by John
02:05
created
src/LEOrder.php 1 patch
Indentation   +749 added lines, -749 removed lines patch added patch discarded remove patch
@@ -16,541 +16,541 @@  discard block
 block discarded – undo
16 16
  */
17 17
 class LEOrder
18 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->post($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'])) {
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'])) {
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->post($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'])) {
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
-    /*
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->post($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'])) {
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'])) {
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->post($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'])) {
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 547
      * Deactivate an LetsEncrypt Authorization instance.
548 548
      *
549 549
      * @param string $identifier The domain name for which the verification should be deactivated.
550 550
      *
551 551
      * @return boolean  Returns true is the deactivation request was successful, false if not.
552 552
      */
553
-    /*
553
+	/*
554 554
     public function deactivateOrderAuthorization($identifier)
555 555
     {
556 556
         foreach ($this->authorizations as $auth) {
@@ -575,37 +575,37 @@  discard block
 block discarded – undo
575 575
     }
576 576
     */
577 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 = .
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 609
 			RANDFILE = $ENV::HOME/.rnd
610 610
 			[ req ]
611 611
 			default_bits = 4096
@@ -618,193 +618,193 @@  discard block
 block discarded – undo
618 618
 			basicConstraints = CA:FALSE
619 619
 			subjectAltName = ' . $san . '
620 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'])) {
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->post($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
-    }
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'])) {
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->post($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 810
 }
Please login to merge, or discard this patch.
src/LEConnector.php 1 patch
Indentation   +331 added lines, -331 removed lines patch added patch discarded remove patch
@@ -21,335 +21,335 @@
 block discarded – undo
21 21
  */
22 22
 class LEConnector
23 23
 {
24
-    public $baseURL;
25
-
26
-    private $nonce;
27
-
28
-    public $keyChange;
29
-    public $newAccount;
30
-    public $newNonce;
31
-    public $newOrder;
32
-    public $revokeCert;
33
-
34
-    public $accountURL;
35
-    public $accountDeactivated = false;
36
-
37
-    /** @var LoggerInterface */
38
-    private $log;
39
-
40
-    /** @var ClientInterface */
41
-    private $httpClient;
42
-
43
-    /** @var CertificateStorageInterface */
44
-    private $storage;
45
-
46
-    /**
47
-     * Initiates the LetsEncrypt Connector class.
48
-     *
49
-     * @param LoggerInterface $log
50
-     * @param ClientInterface $httpClient
51
-     * @param string $baseURL The LetsEncrypt server URL to make requests to.
52
-     * @param CertificateStorageInterface $storage
53
-     */
54
-    public function __construct(
55
-        LoggerInterface $log,
56
-        ClientInterface $httpClient,
57
-        $baseURL,
58
-        CertificateStorageInterface $storage
59
-    ) {
60
-
61
-        $this->baseURL = $baseURL;
62
-        $this->storage = $storage;
63
-        $this->log = $log;
64
-        $this->httpClient = $httpClient;
65
-
66
-        $this->getLEDirectory();
67
-        $this->getNewNonce();
68
-    }
69
-
70
-    /**
71
-     * Requests the LetsEncrypt Directory and stores the necessary URLs in this LetsEncrypt Connector instance.
72
-     */
73
-    private function getLEDirectory()
74
-    {
75
-        $req = $this->post('/directory');
76
-        $this->keyChange = $req['body']['keyChange'];
77
-        $this->newAccount = $req['body']['newAccount'];
78
-        $this->newNonce = $req['body']['newNonce'];
79
-        $this->newOrder = $req['body']['newOrder'];
80
-        $this->revokeCert = $req['body']['revokeCert'];
81
-    }
82
-
83
-    /**
84
-     * Requests a new nonce from the LetsEncrypt server and stores it in this LetsEncrypt Connector instance.
85
-     */
86
-    private function getNewNonce()
87
-    {
88
-        $result = $this->head($this->newNonce);
89
-
90
-        if ($result['status'] !== 200) {
91
-            //@codeCoverageIgnoreStart
92
-            throw new RuntimeException("No new nonce - fetched {$this->newNonce} got " . $result['header']);
93
-            //@codeCoverageIgnoreEnd
94
-        }
95
-    }
96
-
97
-    /**
98
-     * Makes a request to the HTTP challenge URL and checks whether the authorization is valid for the given $domain.
99
-     *
100
-     * @param string $domain The domain to check the authorization for.
101
-     * @param string $token The token (filename) to request.
102
-     * @param string $keyAuthorization the keyAuthorization (file content) to compare.
103
-     *
104
-     * @return boolean  Returns true if the challenge is valid, false if not.
105
-     */
106
-    public function checkHTTPChallenge($domain, $token, $keyAuthorization)
107
-    {
108
-        $requestURL = $domain . '/.well-known/acme-challenge/' . $token;
109
-
110
-        $request = new Request('POST', $requestURL);
111
-
112
-        try {
113
-            $response = $this->httpClient->send($request);
114
-        } catch (\Exception $e) {
115
-            $this->log->warning(
116
-                "HTTP check on $requestURL failed ({msg})",
117
-                ['msg' => $e->getMessage()]
118
-            );
119
-            return false;
120
-        }
121
-
122
-        $content = $response->getBody()->getContents();
123
-        return $content == $keyAuthorization;
124
-    }
125
-
126
-    /**
127
-     * Makes a Curl request.
128
-     *
129
-     * @param string $method The HTTP method to use. Accepting GET, POST and HEAD requests.
130
-     * @param string $URL The URL or partial URL to make the request to.
131
-     *                       If it is partial, the baseURL will be prepended.
132
-     * @param string $data The body to attach to a POST request. Expected as a JSON encoded string.
133
-     *
134
-     * @return array Returns an array with the keys 'request', 'header' and 'body'.
135
-     */
136
-    private function request($method, $URL, $data = null)
137
-    {
138
-        if ($this->accountDeactivated) {
139
-            throw new LogicException('The account was deactivated. No further requests can be made.');
140
-        }
141
-
142
-        $requestURL = preg_match('~^http~', $URL) ? $URL : $this->baseURL . $URL;
143
-
144
-        $hdrs = ['Accept' => 'application/json'];
145
-        if (!empty($data)) {
146
-            $hdrs['Content-Type'] = 'application/jose+json';
147
-        }
148
-
149
-        $request = new Request($method, $requestURL, $hdrs, $data);
150
-
151
-        try {
152
-            $response = $this->httpClient->send($request);
153
-        } catch (BadResponseException $e) {
154
-            //4xx/5xx failures are not expected and we throw exceptions for them
155
-            $msg = "$method $URL failed";
156
-            if ($e->hasResponse()) {
157
-                $body = (string)$e->getResponse()->getBody();
158
-                $json = json_decode($body, true);
159
-                if (!empty($json) && isset($json['detail'])) {
160
-                    $msg .= " ({$json['detail']})";
161
-                }
162
-            }
163
-            throw new RuntimeException($msg, 0, $e);
164
-        } catch (GuzzleException $e) {
165
-            //@codeCoverageIgnoreStart
166
-            throw new RuntimeException("$method $URL failed", 0, $e);
167
-            //@codeCoverageIgnoreEnd
168
-        }
169
-
170
-        //uncomment this to generate a test simulation of this request
171
-        //TestResponseGenerator::dumpTestSimulation($method, $requestURL, $response);
172
-
173
-        $this->maintainNonce($method, $response);
174
-
175
-        return $this->formatResponse($method, $requestURL, $response);
176
-    }
177
-
178
-    private function formatResponse($method, $requestURL, ResponseInterface $response)
179
-    {
180
-        $body = $response->getBody();
181
-
182
-        $header = $response->getStatusCode() . ' ' . $response->getReasonPhrase() . "\n";
183
-        $allHeaders = $response->getHeaders();
184
-        foreach ($allHeaders as $name => $values) {
185
-            foreach ($values as $value) {
186
-                $header .= "$name: $value\n";
187
-            }
188
-        }
189
-
190
-        $decoded = $body;
191
-        if ($response->getHeaderLine('Content-Type') === 'application/json') {
192
-            $decoded = json_decode($body, true);
193
-            if (!$decoded) {
194
-                //@codeCoverageIgnoreStart
195
-                throw new RuntimeException('Bad JSON received ' . $body);
196
-                //@codeCoverageIgnoreEnd
197
-            }
198
-        }
199
-
200
-        $jsonresponse = [
201
-            'request' => $method . ' ' . $requestURL,
202
-            'header' => $header,
203
-            'body' => $decoded,
204
-            'raw' => $body,
205
-            'status' => $response->getStatusCode()
206
-        ];
207
-
208
-        //$this->log->debug('{request} got {status} header = {header} body = {raw}', $jsonresponse);
209
-
210
-        return $jsonresponse;
211
-    }
212
-
213
-    private function maintainNonce($requestMethod, ResponseInterface $response)
214
-    {
215
-        if ($response->hasHeader('Replay-Nonce')) {
216
-            $this->nonce = $response->getHeader('Replay-Nonce')[0];
217
-            $this->log->debug("got new nonce " . $this->nonce);
218
-        } elseif ($requestMethod == 'POST') {
219
-            $this->getNewNonce(); // Not expecting a new nonce with GET and HEAD requests.
220
-        }
221
-    }
222
-
223
-    /**
224
-     * Makes a GET request.
225
-     *
226
-     * @param string $url The URL or partial URL to make the request to.
227
-     *                    If it is partial, the baseURL will be prepended.
228
-     *
229
-     * @return array Returns an array with the keys 'request', 'header' and 'body'.
230
-     */
231
-    public function get($url)
232
-    {
233
-        return $this->request('GET', $url);
234
-    }
235
-
236
-    /**
237
-     * Makes a POST request.
238
-     *
239
-     * @param string $url The URL or partial URL for the request to. If it is partial, the baseURL will be prepended.
240
-     * @param string $data The body to attach to a POST request. Expected as a json string.
241
-     *
242
-     * @return array Returns an array with the keys 'request', 'header' and 'body'.
243
-     */
244
-    public function post($url, $data = null)
245
-    {
246
-        return $this->request('POST', $url, $data);
247
-    }
248
-
249
-    /**
250
-     * Makes a HEAD request.
251
-     *
252
-     * @param string $url The URL or partial URL to make the request to.
253
-     *                    If it is partial, the baseURL will be prepended.
254
-     *
255
-     * @return array Returns an array with the keys 'request', 'header' and 'body'.
256
-     */
257
-    public function head($url)
258
-    {
259
-        return $this->request('HEAD', $url);
260
-    }
261
-
262
-    /**
263
-     * Generates a JSON Web Key signature to attach to the request.
264
-     *
265
-     * @param array|string $payload The payload to add to the signature.
266
-     * @param string $url The URL to use in the signature.
267
-     * @param string $privateKey The private key to sign the request with.
268
-     *
269
-     * @return string   Returns a JSON encoded string containing the signature.
270
-     */
271
-    public function signRequestJWK($payload, $url, $privateKey = '')
272
-    {
273
-        if ($privateKey == '') {
274
-            $privateKey = $this->storage->getAccountPrivateKey();
275
-        }
276
-        $privateKey = openssl_pkey_get_private($privateKey);
277
-        if ($privateKey === false) {
278
-            //@codeCoverageIgnoreStart
279
-            throw new RuntimeException('LEConnector::signRequestJWK failed to get private key');
280
-            //@codeCoverageIgnoreEnd
281
-        }
282
-
283
-        $details = openssl_pkey_get_details($privateKey);
284
-
285
-        $protected = [
286
-            "alg" => "RS256",
287
-            "jwk" => [
288
-                "kty" => "RSA",
289
-                "n" => LEFunctions::base64UrlSafeEncode($details["rsa"]["n"]),
290
-                "e" => LEFunctions::base64UrlSafeEncode($details["rsa"]["e"]),
291
-            ],
292
-            "nonce" => $this->nonce,
293
-            "url" => $url
294
-        ];
295
-
296
-        $payload64 = LEFunctions::base64UrlSafeEncode(
297
-            str_replace('\\/', '/', is_array($payload) ? json_encode($payload) : $payload)
298
-        );
299
-        $protected64 = LEFunctions::base64UrlSafeEncode(json_encode($protected));
300
-
301
-        openssl_sign($protected64 . '.' . $payload64, $signed, $privateKey, OPENSSL_ALGO_SHA256);
302
-        $signed64 = LEFunctions::base64UrlSafeEncode($signed);
303
-
304
-        $data = [
305
-            'protected' => $protected64,
306
-            'payload' => $payload64,
307
-            'signature' => $signed64
308
-        ];
309
-
310
-        return json_encode($data);
311
-    }
312
-
313
-    /**
314
-     * Generates a Key ID signature to attach to the request.
315
-     *
316
-     * @param array|string $payload The payload to add to the signature.
317
-     * @param string $kid The Key ID to use in the signature.
318
-     * @param string $url The URL to use in the signature.
319
-     * @param string $privateKey The private key to sign the request with. Defaults to account key
320
-     *
321
-     * @return string   Returns a JSON encoded string containing the signature.
322
-     */
323
-    public function signRequestKid($payload, $kid, $url, $privateKey = '')
324
-    {
325
-        if ($privateKey == '') {
326
-            $privateKey = $this->storage->getAccountPrivateKey();
327
-        }
328
-        $privateKey = openssl_pkey_get_private($privateKey);
329
-
330
-        //$details = openssl_pkey_get_details($privateKey);
331
-
332
-        $protected = [
333
-            "alg" => "RS256",
334
-            "kid" => $kid,
335
-            "nonce" => $this->nonce,
336
-            "url" => $url
337
-        ];
338
-
339
-        $payload64 = LEFunctions::base64UrlSafeEncode(
340
-            str_replace('\\/', '/', is_array($payload) ? json_encode($payload) : $payload)
341
-        );
342
-        $protected64 = LEFunctions::base64UrlSafeEncode(json_encode($protected));
343
-
344
-        openssl_sign($protected64 . '.' . $payload64, $signed, $privateKey, OPENSSL_ALGO_SHA256);
345
-        $signed64 = LEFunctions::base64UrlSafeEncode($signed);
346
-
347
-        $data = [
348
-            'protected' => $protected64,
349
-            'payload' => $payload64,
350
-            'signature' => $signed64
351
-        ];
352
-
353
-        return json_encode($data);
354
-    }
24
+	public $baseURL;
25
+
26
+	private $nonce;
27
+
28
+	public $keyChange;
29
+	public $newAccount;
30
+	public $newNonce;
31
+	public $newOrder;
32
+	public $revokeCert;
33
+
34
+	public $accountURL;
35
+	public $accountDeactivated = false;
36
+
37
+	/** @var LoggerInterface */
38
+	private $log;
39
+
40
+	/** @var ClientInterface */
41
+	private $httpClient;
42
+
43
+	/** @var CertificateStorageInterface */
44
+	private $storage;
45
+
46
+	/**
47
+	 * Initiates the LetsEncrypt Connector class.
48
+	 *
49
+	 * @param LoggerInterface $log
50
+	 * @param ClientInterface $httpClient
51
+	 * @param string $baseURL The LetsEncrypt server URL to make requests to.
52
+	 * @param CertificateStorageInterface $storage
53
+	 */
54
+	public function __construct(
55
+		LoggerInterface $log,
56
+		ClientInterface $httpClient,
57
+		$baseURL,
58
+		CertificateStorageInterface $storage
59
+	) {
60
+
61
+		$this->baseURL = $baseURL;
62
+		$this->storage = $storage;
63
+		$this->log = $log;
64
+		$this->httpClient = $httpClient;
65
+
66
+		$this->getLEDirectory();
67
+		$this->getNewNonce();
68
+	}
69
+
70
+	/**
71
+	 * Requests the LetsEncrypt Directory and stores the necessary URLs in this LetsEncrypt Connector instance.
72
+	 */
73
+	private function getLEDirectory()
74
+	{
75
+		$req = $this->post('/directory');
76
+		$this->keyChange = $req['body']['keyChange'];
77
+		$this->newAccount = $req['body']['newAccount'];
78
+		$this->newNonce = $req['body']['newNonce'];
79
+		$this->newOrder = $req['body']['newOrder'];
80
+		$this->revokeCert = $req['body']['revokeCert'];
81
+	}
82
+
83
+	/**
84
+	 * Requests a new nonce from the LetsEncrypt server and stores it in this LetsEncrypt Connector instance.
85
+	 */
86
+	private function getNewNonce()
87
+	{
88
+		$result = $this->head($this->newNonce);
89
+
90
+		if ($result['status'] !== 200) {
91
+			//@codeCoverageIgnoreStart
92
+			throw new RuntimeException("No new nonce - fetched {$this->newNonce} got " . $result['header']);
93
+			//@codeCoverageIgnoreEnd
94
+		}
95
+	}
96
+
97
+	/**
98
+	 * Makes a request to the HTTP challenge URL and checks whether the authorization is valid for the given $domain.
99
+	 *
100
+	 * @param string $domain The domain to check the authorization for.
101
+	 * @param string $token The token (filename) to request.
102
+	 * @param string $keyAuthorization the keyAuthorization (file content) to compare.
103
+	 *
104
+	 * @return boolean  Returns true if the challenge is valid, false if not.
105
+	 */
106
+	public function checkHTTPChallenge($domain, $token, $keyAuthorization)
107
+	{
108
+		$requestURL = $domain . '/.well-known/acme-challenge/' . $token;
109
+
110
+		$request = new Request('POST', $requestURL);
111
+
112
+		try {
113
+			$response = $this->httpClient->send($request);
114
+		} catch (\Exception $e) {
115
+			$this->log->warning(
116
+				"HTTP check on $requestURL failed ({msg})",
117
+				['msg' => $e->getMessage()]
118
+			);
119
+			return false;
120
+		}
121
+
122
+		$content = $response->getBody()->getContents();
123
+		return $content == $keyAuthorization;
124
+	}
125
+
126
+	/**
127
+	 * Makes a Curl request.
128
+	 *
129
+	 * @param string $method The HTTP method to use. Accepting GET, POST and HEAD requests.
130
+	 * @param string $URL The URL or partial URL to make the request to.
131
+	 *                       If it is partial, the baseURL will be prepended.
132
+	 * @param string $data The body to attach to a POST request. Expected as a JSON encoded string.
133
+	 *
134
+	 * @return array Returns an array with the keys 'request', 'header' and 'body'.
135
+	 */
136
+	private function request($method, $URL, $data = null)
137
+	{
138
+		if ($this->accountDeactivated) {
139
+			throw new LogicException('The account was deactivated. No further requests can be made.');
140
+		}
141
+
142
+		$requestURL = preg_match('~^http~', $URL) ? $URL : $this->baseURL . $URL;
143
+
144
+		$hdrs = ['Accept' => 'application/json'];
145
+		if (!empty($data)) {
146
+			$hdrs['Content-Type'] = 'application/jose+json';
147
+		}
148
+
149
+		$request = new Request($method, $requestURL, $hdrs, $data);
150
+
151
+		try {
152
+			$response = $this->httpClient->send($request);
153
+		} catch (BadResponseException $e) {
154
+			//4xx/5xx failures are not expected and we throw exceptions for them
155
+			$msg = "$method $URL failed";
156
+			if ($e->hasResponse()) {
157
+				$body = (string)$e->getResponse()->getBody();
158
+				$json = json_decode($body, true);
159
+				if (!empty($json) && isset($json['detail'])) {
160
+					$msg .= " ({$json['detail']})";
161
+				}
162
+			}
163
+			throw new RuntimeException($msg, 0, $e);
164
+		} catch (GuzzleException $e) {
165
+			//@codeCoverageIgnoreStart
166
+			throw new RuntimeException("$method $URL failed", 0, $e);
167
+			//@codeCoverageIgnoreEnd
168
+		}
169
+
170
+		//uncomment this to generate a test simulation of this request
171
+		//TestResponseGenerator::dumpTestSimulation($method, $requestURL, $response);
172
+
173
+		$this->maintainNonce($method, $response);
174
+
175
+		return $this->formatResponse($method, $requestURL, $response);
176
+	}
177
+
178
+	private function formatResponse($method, $requestURL, ResponseInterface $response)
179
+	{
180
+		$body = $response->getBody();
181
+
182
+		$header = $response->getStatusCode() . ' ' . $response->getReasonPhrase() . "\n";
183
+		$allHeaders = $response->getHeaders();
184
+		foreach ($allHeaders as $name => $values) {
185
+			foreach ($values as $value) {
186
+				$header .= "$name: $value\n";
187
+			}
188
+		}
189
+
190
+		$decoded = $body;
191
+		if ($response->getHeaderLine('Content-Type') === 'application/json') {
192
+			$decoded = json_decode($body, true);
193
+			if (!$decoded) {
194
+				//@codeCoverageIgnoreStart
195
+				throw new RuntimeException('Bad JSON received ' . $body);
196
+				//@codeCoverageIgnoreEnd
197
+			}
198
+		}
199
+
200
+		$jsonresponse = [
201
+			'request' => $method . ' ' . $requestURL,
202
+			'header' => $header,
203
+			'body' => $decoded,
204
+			'raw' => $body,
205
+			'status' => $response->getStatusCode()
206
+		];
207
+
208
+		//$this->log->debug('{request} got {status} header = {header} body = {raw}', $jsonresponse);
209
+
210
+		return $jsonresponse;
211
+	}
212
+
213
+	private function maintainNonce($requestMethod, ResponseInterface $response)
214
+	{
215
+		if ($response->hasHeader('Replay-Nonce')) {
216
+			$this->nonce = $response->getHeader('Replay-Nonce')[0];
217
+			$this->log->debug("got new nonce " . $this->nonce);
218
+		} elseif ($requestMethod == 'POST') {
219
+			$this->getNewNonce(); // Not expecting a new nonce with GET and HEAD requests.
220
+		}
221
+	}
222
+
223
+	/**
224
+	 * Makes a GET request.
225
+	 *
226
+	 * @param string $url The URL or partial URL to make the request to.
227
+	 *                    If it is partial, the baseURL will be prepended.
228
+	 *
229
+	 * @return array Returns an array with the keys 'request', 'header' and 'body'.
230
+	 */
231
+	public function get($url)
232
+	{
233
+		return $this->request('GET', $url);
234
+	}
235
+
236
+	/**
237
+	 * Makes a POST request.
238
+	 *
239
+	 * @param string $url The URL or partial URL for the request to. If it is partial, the baseURL will be prepended.
240
+	 * @param string $data The body to attach to a POST request. Expected as a json string.
241
+	 *
242
+	 * @return array Returns an array with the keys 'request', 'header' and 'body'.
243
+	 */
244
+	public function post($url, $data = null)
245
+	{
246
+		return $this->request('POST', $url, $data);
247
+	}
248
+
249
+	/**
250
+	 * Makes a HEAD request.
251
+	 *
252
+	 * @param string $url The URL or partial URL to make the request to.
253
+	 *                    If it is partial, the baseURL will be prepended.
254
+	 *
255
+	 * @return array Returns an array with the keys 'request', 'header' and 'body'.
256
+	 */
257
+	public function head($url)
258
+	{
259
+		return $this->request('HEAD', $url);
260
+	}
261
+
262
+	/**
263
+	 * Generates a JSON Web Key signature to attach to the request.
264
+	 *
265
+	 * @param array|string $payload The payload to add to the signature.
266
+	 * @param string $url The URL to use in the signature.
267
+	 * @param string $privateKey The private key to sign the request with.
268
+	 *
269
+	 * @return string   Returns a JSON encoded string containing the signature.
270
+	 */
271
+	public function signRequestJWK($payload, $url, $privateKey = '')
272
+	{
273
+		if ($privateKey == '') {
274
+			$privateKey = $this->storage->getAccountPrivateKey();
275
+		}
276
+		$privateKey = openssl_pkey_get_private($privateKey);
277
+		if ($privateKey === false) {
278
+			//@codeCoverageIgnoreStart
279
+			throw new RuntimeException('LEConnector::signRequestJWK failed to get private key');
280
+			//@codeCoverageIgnoreEnd
281
+		}
282
+
283
+		$details = openssl_pkey_get_details($privateKey);
284
+
285
+		$protected = [
286
+			"alg" => "RS256",
287
+			"jwk" => [
288
+				"kty" => "RSA",
289
+				"n" => LEFunctions::base64UrlSafeEncode($details["rsa"]["n"]),
290
+				"e" => LEFunctions::base64UrlSafeEncode($details["rsa"]["e"]),
291
+			],
292
+			"nonce" => $this->nonce,
293
+			"url" => $url
294
+		];
295
+
296
+		$payload64 = LEFunctions::base64UrlSafeEncode(
297
+			str_replace('\\/', '/', is_array($payload) ? json_encode($payload) : $payload)
298
+		);
299
+		$protected64 = LEFunctions::base64UrlSafeEncode(json_encode($protected));
300
+
301
+		openssl_sign($protected64 . '.' . $payload64, $signed, $privateKey, OPENSSL_ALGO_SHA256);
302
+		$signed64 = LEFunctions::base64UrlSafeEncode($signed);
303
+
304
+		$data = [
305
+			'protected' => $protected64,
306
+			'payload' => $payload64,
307
+			'signature' => $signed64
308
+		];
309
+
310
+		return json_encode($data);
311
+	}
312
+
313
+	/**
314
+	 * Generates a Key ID signature to attach to the request.
315
+	 *
316
+	 * @param array|string $payload The payload to add to the signature.
317
+	 * @param string $kid The Key ID to use in the signature.
318
+	 * @param string $url The URL to use in the signature.
319
+	 * @param string $privateKey The private key to sign the request with. Defaults to account key
320
+	 *
321
+	 * @return string   Returns a JSON encoded string containing the signature.
322
+	 */
323
+	public function signRequestKid($payload, $kid, $url, $privateKey = '')
324
+	{
325
+		if ($privateKey == '') {
326
+			$privateKey = $this->storage->getAccountPrivateKey();
327
+		}
328
+		$privateKey = openssl_pkey_get_private($privateKey);
329
+
330
+		//$details = openssl_pkey_get_details($privateKey);
331
+
332
+		$protected = [
333
+			"alg" => "RS256",
334
+			"kid" => $kid,
335
+			"nonce" => $this->nonce,
336
+			"url" => $url
337
+		];
338
+
339
+		$payload64 = LEFunctions::base64UrlSafeEncode(
340
+			str_replace('\\/', '/', is_array($payload) ? json_encode($payload) : $payload)
341
+		);
342
+		$protected64 = LEFunctions::base64UrlSafeEncode(json_encode($protected));
343
+
344
+		openssl_sign($protected64 . '.' . $payload64, $signed, $privateKey, OPENSSL_ALGO_SHA256);
345
+		$signed64 = LEFunctions::base64UrlSafeEncode($signed);
346
+
347
+		$data = [
348
+			'protected' => $protected64,
349
+			'payload' => $payload64,
350
+			'signature' => $signed64
351
+		];
352
+
353
+		return json_encode($data);
354
+	}
355 355
 }
Please login to merge, or discard this patch.
src/LEAuthorization.php 1 patch
Indentation   +76 added lines, -76 removed lines patch added patch discarded remove patch
@@ -13,88 +13,88 @@
 block discarded – undo
13 13
  */
14 14
 class LEAuthorization
15 15
 {
16
-    private $connector;
16
+	private $connector;
17 17
 
18
-    public $authorizationURL;
19
-    public $identifier;
20
-    public $status;
21
-    public $expires;
22
-    public $challenges;
18
+	public $authorizationURL;
19
+	public $identifier;
20
+	public $status;
21
+	public $expires;
22
+	public $challenges;
23 23
 
24
-    /** @var LoggerInterface  */
25
-    private $log;
24
+	/** @var LoggerInterface  */
25
+	private $log;
26 26
 
27
-    /**
28
-     * Initiates the LetsEncrypt Authorization class. Child of a LetsEncrypt Order instance.
29
-     *
30
-     * @param LEConnector $connector The LetsEncrypt Connector instance to use for HTTP requests.
31
-     * @param LoggerInterface $log PSR-3 logger
32
-     * @param string $authorizationURL The URL of the authorization, given by a LetsEncrypt order request.
33
-     */
34
-    public function __construct($connector, LoggerInterface $log, $authorizationURL)
35
-    {
36
-        $this->connector = $connector;
37
-        $this->log = $log;
38
-        $this->authorizationURL = $authorizationURL;
27
+	/**
28
+	 * Initiates the LetsEncrypt Authorization class. Child of a LetsEncrypt Order instance.
29
+	 *
30
+	 * @param LEConnector $connector The LetsEncrypt Connector instance to use for HTTP requests.
31
+	 * @param LoggerInterface $log PSR-3 logger
32
+	 * @param string $authorizationURL The URL of the authorization, given by a LetsEncrypt order request.
33
+	 */
34
+	public function __construct($connector, LoggerInterface $log, $authorizationURL)
35
+	{
36
+		$this->connector = $connector;
37
+		$this->log = $log;
38
+		$this->authorizationURL = $authorizationURL;
39 39
 
40
-        $sign = $this->connector->signRequestKid(
41
-            null,
42
-            $this->connector->accountURL,
43
-            $this->authorizationURL
44
-        );
40
+		$sign = $this->connector->signRequestKid(
41
+			null,
42
+			$this->connector->accountURL,
43
+			$this->authorizationURL
44
+		);
45 45
 
46
-        $post = $this->connector->post($this->authorizationURL, $sign);
47
-        if ($post['status'] === 200) {
48
-            $this->identifier = $post['body']['identifier'];
49
-            $this->status = $post['body']['status'];
50
-            $this->expires = $post['body']['expires'];
51
-            $this->challenges = $post['body']['challenges'];
52
-        } else {
53
-            //@codeCoverageIgnoreStart
54
-            $this->log->error("LEAuthorization::__construct cannot find authorization $authorizationURL");
55
-            //@codeCoverageIgnoreEnd
56
-        }
57
-    }
46
+		$post = $this->connector->post($this->authorizationURL, $sign);
47
+		if ($post['status'] === 200) {
48
+			$this->identifier = $post['body']['identifier'];
49
+			$this->status = $post['body']['status'];
50
+			$this->expires = $post['body']['expires'];
51
+			$this->challenges = $post['body']['challenges'];
52
+		} else {
53
+			//@codeCoverageIgnoreStart
54
+			$this->log->error("LEAuthorization::__construct cannot find authorization $authorizationURL");
55
+			//@codeCoverageIgnoreEnd
56
+		}
57
+	}
58 58
 
59
-    /**
60
-     * Updates the data associated with the current LetsEncrypt Authorization instance.
61
-     */
59
+	/**
60
+	 * Updates the data associated with the current LetsEncrypt Authorization instance.
61
+	 */
62 62
 
63
-    public function updateData()
64
-    {
65
-        $get = $this->connector->post($this->authorizationURL);
66
-        if ($get['status'] === 200) {
67
-            $this->identifier = $get['body']['identifier'];
68
-            $this->status = $get['body']['status'];
69
-            $this->expires = $get['body']['expires'];
70
-            $this->challenges = $get['body']['challenges'];
71
-        } else {
72
-            //@codeCoverageIgnoreStart
73
-            $this->log->error("LEAuthorization::updateData cannot find authorization " . $this->authorizationURL);
74
-            //@codeCoverageIgnoreEnd
75
-        }
76
-    }
63
+	public function updateData()
64
+	{
65
+		$get = $this->connector->post($this->authorizationURL);
66
+		if ($get['status'] === 200) {
67
+			$this->identifier = $get['body']['identifier'];
68
+			$this->status = $get['body']['status'];
69
+			$this->expires = $get['body']['expires'];
70
+			$this->challenges = $get['body']['challenges'];
71
+		} else {
72
+			//@codeCoverageIgnoreStart
73
+			$this->log->error("LEAuthorization::updateData cannot find authorization " . $this->authorizationURL);
74
+			//@codeCoverageIgnoreEnd
75
+		}
76
+	}
77 77
 
78
-    /**
79
-     * Gets the challenge of the given $type for this LetsEncrypt Authorization instance.
80
-     * Throws a Runtime Exception if the given $type is not found in this LetsEncrypt Authorization instance.
81
-     *
82
-     * @param string $type The type of verification.
83
-     *                     Supporting LEOrder::CHALLENGE_TYPE_HTTP and LEOrder::CHALLENGE_TYPE_DNS.
84
-     *
85
-     * @return array Returns an array with the challenge of the requested $type.
86
-     */
87
-    public function getChallenge($type)
88
-    {
89
-        foreach ($this->challenges as $challenge) {
90
-            if ($challenge['type'] == $type) {
91
-                return $challenge;
92
-            }
93
-        }
94
-        //@codeCoverageIgnoreStart
95
-        throw new RuntimeException(
96
-            'No challenge found for type \'' . $type . '\' and identifier \'' . $this->identifier['value'] . '\'.'
97
-        );
98
-        //@codeCoverageIgnoreEnd
99
-    }
78
+	/**
79
+	 * Gets the challenge of the given $type for this LetsEncrypt Authorization instance.
80
+	 * Throws a Runtime Exception if the given $type is not found in this LetsEncrypt Authorization instance.
81
+	 *
82
+	 * @param string $type The type of verification.
83
+	 *                     Supporting LEOrder::CHALLENGE_TYPE_HTTP and LEOrder::CHALLENGE_TYPE_DNS.
84
+	 *
85
+	 * @return array Returns an array with the challenge of the requested $type.
86
+	 */
87
+	public function getChallenge($type)
88
+	{
89
+		foreach ($this->challenges as $challenge) {
90
+			if ($challenge['type'] == $type) {
91
+				return $challenge;
92
+			}
93
+		}
94
+		//@codeCoverageIgnoreStart
95
+		throw new RuntimeException(
96
+			'No challenge found for type \'' . $type . '\' and identifier \'' . $this->identifier['value'] . '\'.'
97
+		);
98
+		//@codeCoverageIgnoreEnd
99
+	}
100 100
 }
Please login to merge, or discard this patch.