LEConnector   A
last analyzed

Complexity

Total Complexity 33

Size/Duplication

Total Lines 332
Duplicated Lines 0 %

Test Coverage

Coverage 90.76%

Importance

Changes 0
Metric Value
eloc 128
dl 0
loc 332
ccs 108
cts 119
cp 0.9076
rs 9.76
c 0
b 0
f 0
wmc 33

12 Methods

Rating   Name   Duplication   Size   Complexity  
A __construct() 0 14 1
A checkHTTPChallenge() 0 18 2
B request() 0 40 9
A getLEDirectory() 0 8 1
A signRequestKid() 0 31 3
A post() 0 3 1
A signRequestJWK() 0 40 4
A head() 0 3 1
A formatResponse() 0 33 5
A getNewNonce() 0 7 2
A get() 0 3 1
A maintainNonce() 0 7 3
1
<?php
2
3
namespace Zwartpet\PHPCertificateToolbox;
4
5
use Zwartpet\PHPCertificateToolbox\Exception\LogicException;
6
use Zwartpet\PHPCertificateToolbox\Exception\RuntimeException;
7
use GuzzleHttp\ClientInterface;
8
use GuzzleHttp\Exception\BadResponseException;
9
use GuzzleHttp\Psr7\Request;
10
use GuzzleHttp\Exception\GuzzleException;
11
use Psr\Http\Message\ResponseInterface;
12
use Psr\Log\LoggerInterface;
13
14
/**
15
 * LetsEncrypt Connector class, containing the functions necessary to sign with JSON Web Key and Key ID, and perform
16
 * GET, POST and HEAD requests.
17
 *
18
 * @author     Youri van Weegberg <[email protected]>
19
 * @copyright  2018 Youri van Weegberg
20
 * @license    https://opensource.org/licenses/mit-license.php  MIT License
21
 */
22
class LEConnector
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 AccountStorageInterface */
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 AccountStorageInterface $storage
53
     */
54 14
    public function __construct(
55
        LoggerInterface $log,
56
        ClientInterface $httpClient,
57
        $baseURL,
58
        AccountStorageInterface $storage
59
    ) {
60
61 14
        $this->baseURL = $baseURL;
62 14
        $this->storage = $storage;
63 14
        $this->log = $log;
64 14
        $this->httpClient = $httpClient;
65
66 14
        $this->getLEDirectory();
67 10
        $this->getNewNonce();
68 10
    }
69
70
    /**
71
     * Requests the LetsEncrypt Directory and stores the necessary URLs in this LetsEncrypt Connector instance.
72
     */
73 14
    private function getLEDirectory()
74
    {
75 14
        $req = $this->get('/directory');
76 10
        $this->keyChange = $req['body']['keyChange'];
77 10
        $this->newAccount = $req['body']['newAccount'];
78 10
        $this->newNonce = $req['body']['newNonce'];
79 10
        $this->newOrder = $req['body']['newOrder'];
80 10
        $this->revokeCert = $req['body']['revokeCert'];
81 10
    }
82
83
    /**
84
     * Requests a new nonce from the LetsEncrypt server and stores it in this LetsEncrypt Connector instance.
85
     */
86 10
    private function getNewNonce()
87
    {
88 10
        $result = $this->head($this->newNonce);
89
90 10
        if ($result['status'] !== 200) {
91
            //@codeCoverageIgnoreStart
92
            throw new RuntimeException("No new nonce - fetched {$this->newNonce} got " . $result['header']);
93
            //@codeCoverageIgnoreEnd
94
        }
95 10
    }
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 = 'http://' . $domain . '/.well-known/acme-challenge/' . $token;
109
110
        $request = new Request('GET', $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 14
    private function request($method, $URL, $data = null)
137
    {
138 14
        if ($this->accountDeactivated) {
139 2
            throw new LogicException('The account was deactivated. No further requests can be made.');
140
        }
141
142 14
        $requestURL = preg_match('~^http~', $URL) ? $URL : $this->baseURL . $URL;
143
144 14
        $hdrs = ['Accept' => 'application/json'];
145 14
        if (!empty($data)) {
146 2
            $hdrs['Content-Type'] = 'application/jose+json';
147
        }
148
149 14
        $request = new Request($method, $requestURL, $hdrs, $data);
150
151
        try {
152 14
            $response = $this->httpClient->send($request);
153 4
        } catch (BadResponseException $e) {
154
            //4xx/5xx failures are not expected and we throw exceptions for them
155 2
            $msg = "$method $URL failed";
156 2
            if ($e->hasResponse()) {
157 2
                $body = (string)$e->getResponse()->getBody();
158 2
                $json = json_decode($body, true);
159 2
                if (!empty($json) && isset($json['detail'])) {
160 2
                    $msg .= " ({$json['detail']})";
161
                }
162
            }
163 2
            throw new RuntimeException($msg, 0, $e);
164 2
        } 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 10
        $this->maintainNonce($method, $response);
174
175 10
        return $this->formatResponse($method, $requestURL, $response);
176
    }
177
178 10
    private function formatResponse($method, $requestURL, ResponseInterface $response)
179
    {
180 10
        $body = $response->getBody();
181
182 10
        $header = $response->getStatusCode() . ' ' . $response->getReasonPhrase() . "\n";
183 10
        $allHeaders = $response->getHeaders();
184 10
        foreach ($allHeaders as $name => $values) {
185 10
            foreach ($values as $value) {
186 10
                $header .= "$name: $value\n";
187
            }
188
        }
189
190 10
        $decoded = $body;
191 10
        if ($response->getHeaderLine('Content-Type') === 'application/json') {
192 10
            $decoded = json_decode($body, true);
193 10
            if (!$decoded) {
194
                //@codeCoverageIgnoreStart
195
                throw new RuntimeException('Bad JSON received ' . $body);
196
                //@codeCoverageIgnoreEnd
197
            }
198
        }
199
200
        $jsonresponse = [
201 10
            'request' => $method . ' ' . $requestURL,
202 10
            'header' => $header,
203 10
            'body' => $decoded,
204 10
            'raw' => $body,
205 10
            'status' => $response->getStatusCode()
206
        ];
207
208
        //$this->log->debug('{request} got {status} header = {header} body = {raw}', $jsonresponse);
209
210 10
        return $jsonresponse;
211
    }
212
213 10
    private function maintainNonce($requestMethod, ResponseInterface $response)
214
    {
215 10
        if ($response->hasHeader('Replay-Nonce')) {
216 10
            $this->nonce = $response->getHeader('Replay-Nonce')[0];
217 10
            $this->log->debug("got new nonce " . $this->nonce);
218 10
        } elseif ($requestMethod == 'POST') {
219 2
            $this->getNewNonce(); // Not expecting a new nonce with GET and HEAD requests.
220
        }
221 10
    }
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 14
    public function get($url)
232
    {
233 14
        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 2
    public function post($url, $data = null)
245
    {
246 2
        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 10
    public function head($url)
258
    {
259 10
        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 4
    public function signRequestJWK($payload, $url, $privateKey = '')
272
    {
273 4
        if ($privateKey == '') {
274 4
            $privateKey = $this->storage->getAccountPrivateKey();
275
        }
276 4
        $privateKey = openssl_pkey_get_private($privateKey);
277 4
        if ($privateKey === false) {
278
            //@codeCoverageIgnoreStart
279
            throw new RuntimeException('LEConnector::signRequestJWK failed to get private key');
280
            //@codeCoverageIgnoreEnd
281
        }
282
283 4
        $details = openssl_pkey_get_details($privateKey);
284
285
        $protected = [
286 4
            "alg" => "RS256",
287
            "jwk" => [
288 4
                "kty" => "RSA",
289 4
                "n" => LEFunctions::base64UrlSafeEncode($details["rsa"]["n"]),
290 4
                "e" => LEFunctions::base64UrlSafeEncode($details["rsa"]["e"]),
291
            ],
292 4
            "nonce" => $this->nonce,
293 4
            "url" => $url
294
        ];
295
296 4
        $payload64 = LEFunctions::base64UrlSafeEncode(
297 4
            str_replace('\\/', '/', is_array($payload) ? json_encode($payload) : $payload)
298
        );
299 4
        $protected64 = LEFunctions::base64UrlSafeEncode(json_encode($protected));
300
301 4
        openssl_sign($protected64 . '.' . $payload64, $signed, $privateKey, OPENSSL_ALGO_SHA256);
302 4
        $signed64 = LEFunctions::base64UrlSafeEncode($signed);
303
304
        $data = [
305 4
            'protected' => $protected64,
306 4
            'payload' => $payload64,
307 4
            'signature' => $signed64
308
        ];
309
310 4
        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 4
    public function signRequestKid($payload, $kid, $url, $privateKey = '')
324
    {
325 4
        if ($privateKey == '') {
326 4
            $privateKey = $this->storage->getAccountPrivateKey();
327
        }
328 4
        $privateKey = openssl_pkey_get_private($privateKey);
329
330
        //$details = openssl_pkey_get_details($privateKey);
331
332
        $protected = [
333 4
            "alg" => "RS256",
334 4
            "kid" => $kid,
335 4
            "nonce" => $this->nonce,
336 4
            "url" => $url
337
        ];
338
339 4
        $payload64 = LEFunctions::base64UrlSafeEncode(
340 4
            str_replace('\\/', '/', is_array($payload) ? json_encode($payload) : $payload)
341
        );
342 4
        $protected64 = LEFunctions::base64UrlSafeEncode(json_encode($protected));
343
344 4
        openssl_sign($protected64 . '.' . $payload64, $signed, $privateKey, OPENSSL_ALGO_SHA256);
345 4
        $signed64 = LEFunctions::base64UrlSafeEncode($signed);
346
347
        $data = [
348 4
            'protected' => $protected64,
349 4
            'payload' => $payload64,
350 4
            'signature' => $signed64
351
        ];
352
353 4
        return json_encode($data);
354
    }
355
}
356