1<?php
2
3namespace Analogic\ACME;
4
5class Lescript
6{
7    public $ca = 'https://acme-v01.api.letsencrypt.org';
8    // public $ca = 'https://acme-staging.api.letsencrypt.org'; // testing
9    public $license = 'https://letsencrypt.org/documents/LE-SA-v1.1.1-August-1-2016.pdf';
10    public $countryCode = 'CZ';
11    public $state = "Czech Republic";
12    public $challenge = 'http-01'; // http-01 challange only
13    public $contact = array(); // optional
14    // public $contact = array("mailto:cert-admin@example.com", "tel:+12025551212")
15
16    protected $certificatesDir;
17    protected $webRootDir;
18
19    /** @var \Psr\Log\LoggerInterface */
20    protected $logger;
21    protected $client;
22    protected $accountKeyPath;
23
24    public function __construct($certificatesDir, $webRootDir, $logger = null, ClientInterface $client = null)
25    {
26        $this->certificatesDir = $certificatesDir;
27        $this->webRootDir = $webRootDir;
28        $this->logger = $logger;
29        $this->client = $client ? $client : new Client($this->ca);
30        $this->accountKeyPath = $certificatesDir . '/_account/private.pem';
31    }
32
33    public function initAccount()
34    {
35        if (!is_file($this->accountKeyPath)) {
36
37            // generate and save new private key for account
38            // ---------------------------------------------
39
40            $this->log('Starting new account registration');
41            $this->generateKey(dirname($this->accountKeyPath));
42            $this->postNewReg();
43            $this->log('New account certificate registered');
44
45        } else {
46
47            $this->log('Account already registered. Continuing.');
48
49        }
50    }
51
52    public function signDomains(array $domains, $reuseCsr = false)
53    {
54        $this->log('Starting certificate generation process for domains');
55
56        $privateAccountKey = $this->readPrivateKey($this->accountKeyPath);
57        $accountKeyDetails = openssl_pkey_get_details($privateAccountKey);
58
59        // start domains authentication
60        // ----------------------------
61
62        foreach ($domains as $domain) {
63
64            // 1. getting available authentication options
65            // -------------------------------------------
66
67            $this->log("Requesting challenge for $domain");
68
69            $response = $this->signedRequest(
70                "/acme/new-authz",
71                array("resource" => "new-authz", "identifier" => array("type" => "dns", "value" => $domain))
72            );
73
74            if(empty($response['challenges'])) {
75                throw new \RuntimeException("HTTP Challenge for $domain is not available. Whole response: ".json_encode($response));
76            }
77
78            $self = $this;
79            $challenge = array_reduce($response['challenges'], function ($v, $w) use (&$self) {
80                return $v ? $v : ($w['type'] == $self->challenge ? $w : false);
81            });
82            if (!$challenge) throw new \RuntimeException("HTTP Challenge for $domain is not available. Whole response: " . json_encode($response));
83
84            $this->log("Got challenge token for $domain");
85            $location = $this->client->getLastLocation();
86
87
88            // 2. saving authentication token for web verification
89            // ---------------------------------------------------
90
91            $directory = $this->webRootDir . '/.well-known/acme-challenge';
92            $tokenPath = $directory . '/' . $challenge['token'];
93
94            if (!file_exists($directory) && !@mkdir($directory, 0755, true)) {
95                throw new \RuntimeException("Couldn't create directory to expose challenge: ${tokenPath}");
96            }
97
98            $header = array(
99                // need to be in precise order!
100                "e" => Base64UrlSafeEncoder::encode($accountKeyDetails["rsa"]["e"]),
101                "kty" => "RSA",
102                "n" => Base64UrlSafeEncoder::encode($accountKeyDetails["rsa"]["n"])
103
104            );
105            $payload = $challenge['token'] . '.' . Base64UrlSafeEncoder::encode(hash('sha256', json_encode($header), true));
106
107            file_put_contents($tokenPath, $payload);
108            chmod($tokenPath, 0644);
109
110            // 3. verification process itself
111            // -------------------------------
112
113            $uri = "http://${domain}/.well-known/acme-challenge/${challenge['token']}";
114
115            $this->log("Token for $domain saved at $tokenPath and should be available at $uri");
116
117            // simple self check
118            if ($payload !== trim(@file_get_contents($uri))) {
119                throw new \RuntimeException("Please check $uri - token not available");
120            }
121
122            $this->log("Sending request to challenge");
123
124            // send request to challenge
125            $result = $this->signedRequest(
126                $challenge['uri'],
127                array(
128                    "resource" => "challenge",
129                    "type" => $this->challenge,
130                    "keyAuthorization" => $payload,
131                    "token" => $challenge['token']
132                )
133            );
134
135            // waiting loop
136            do {
137                if (empty($result['status']) || $result['status'] == "invalid") {
138                    throw new \RuntimeException("Verification ended with error: " . json_encode($result));
139                }
140                $ended = !($result['status'] === "pending");
141
142                if (!$ended) {
143                    $this->log("Verification pending, sleeping 1s");
144                    sleep(1);
145                }
146
147                $result = $this->client->get($location);
148
149            } while (!$ended);
150
151            $this->log("Verification ended with status: ${result['status']}");
152            @unlink($tokenPath);
153        }
154
155        // requesting certificate
156        // ----------------------
157        $domainPath = $this->getDomainPath(reset($domains));
158
159        // generate private key for domain if not exist
160        if (!is_dir($domainPath) || !is_file($domainPath . '/private.pem')) {
161            $this->generateKey($domainPath);
162        }
163
164        // load domain key
165        $privateDomainKey = $this->readPrivateKey($domainPath . '/private.pem');
166
167        $this->client->getLastLinks();
168
169        $csr = $reuseCsr && is_file($domainPath . "/last.csr")?
170            $this->getCsrContent($domainPath . "/last.csr") :
171            $this->generateCSR($privateDomainKey, $domains);
172
173        // request certificates creation
174        $result = $this->signedRequest(
175            "/acme/new-cert",
176            array('resource' => 'new-cert', 'csr' => $csr)
177        );
178        if ($this->client->getLastCode() !== 201) {
179            throw new \RuntimeException("Invalid response code: " . $this->client->getLastCode() . ", " . json_encode($result));
180        }
181        $location = $this->client->getLastLocation();
182
183        // waiting loop
184        $certificates = array();
185        while (1) {
186            $this->client->getLastLinks();
187
188            $result = $this->client->get($location);
189
190            if ($this->client->getLastCode() == 202) {
191
192                $this->log("Certificate generation pending, sleeping 1s");
193                sleep(1);
194
195            } else if ($this->client->getLastCode() == 200) {
196
197                $this->log("Got certificate! YAY!");
198                $certificates[] = $this->parsePemFromBody($result);
199
200
201                foreach ($this->client->getLastLinks() as $link) {
202                    $this->log("Requesting chained cert at $link");
203                    $result = $this->client->get($link);
204                    $certificates[] = $this->parsePemFromBody($result);
205                }
206
207                break;
208            } else {
209
210                throw new \RuntimeException("Can't get certificate: HTTP code " . $this->client->getLastCode());
211
212            }
213        }
214
215        if (empty($certificates)) throw new \RuntimeException('No certificates generated');
216
217        $this->log("Saving fullchain.pem");
218        file_put_contents($domainPath . '/fullchain.pem', implode("\n", $certificates));
219
220        $this->log("Saving cert.pem");
221        file_put_contents($domainPath . '/cert.pem', array_shift($certificates));
222
223        $this->log("Saving chain.pem");
224        file_put_contents($domainPath . "/chain.pem", implode("\n", $certificates));
225
226        $this->log("Done !!§§!");
227    }
228
229    protected function readPrivateKey($path)
230    {
231        if (($key = openssl_pkey_get_private('file://' . $path)) === FALSE) {
232            throw new \RuntimeException(openssl_error_string());
233        }
234
235        return $key;
236    }
237
238    protected function parsePemFromBody($body)
239    {
240        $pem = chunk_split(base64_encode($body), 64, "\n");
241        return "-----BEGIN CERTIFICATE-----\n" . $pem . "-----END CERTIFICATE-----\n";
242    }
243
244    protected function getDomainPath($domain)
245    {
246        return $this->certificatesDir . '/' . $domain . '/';
247    }
248
249    protected function postNewReg()
250    {
251        $this->log('Sending registration to letsencrypt server');
252
253        $data = array('resource' => 'new-reg', 'agreement' => $this->license);
254        if(!$this->contact) {
255            $data['contact'] = $this->contact;
256        }
257
258        return $this->signedRequest(
259            '/acme/new-reg',
260            $data
261        );
262    }
263
264    protected function generateCSR($privateKey, array $domains)
265    {
266        $domain = reset($domains);
267        $san = implode(",", array_map(function ($dns) {
268            return "DNS:" . $dns;
269        }, $domains));
270        $tmpConf = tmpfile();
271        $tmpConfMeta = stream_get_meta_data($tmpConf);
272        $tmpConfPath = $tmpConfMeta["uri"];
273
274        // workaround to get SAN working
275        fwrite($tmpConf,
276            'HOME = .
277RANDFILE = $ENV::HOME/.rnd
278[ req ]
279default_bits = 2048
280default_keyfile = privkey.pem
281distinguished_name = req_distinguished_name
282req_extensions = v3_req
283[ req_distinguished_name ]
284countryName = Country Name (2 letter code)
285[ v3_req ]
286basicConstraints = CA:FALSE
287subjectAltName = ' . $san . '
288keyUsage = nonRepudiation, digitalSignature, keyEncipherment');
289
290        $csr = openssl_csr_new(
291            array(
292                "CN" => $domain,
293                "ST" => $this->state,
294                "C" => $this->countryCode,
295                "O" => "Unknown",
296            ),
297            $privateKey,
298            array(
299                "config" => $tmpConfPath,
300                "digest_alg" => "sha256"
301            )
302        );
303
304        if (!$csr) throw new \RuntimeException("CSR couldn't be generated! " . openssl_error_string());
305
306        openssl_csr_export($csr, $csr);
307        fclose($tmpConf);
308
309        $csrPath = $this->getDomainPath($domain) . "/last.csr";
310        file_put_contents($csrPath, $csr);
311
312        return $this->getCsrContent($csrPath);
313    }
314
315    protected function getCsrContent($csrPath) {
316        $csr = file_get_contents($csrPath);
317
318        preg_match('~REQUEST-----(.*)-----END~s', $csr, $matches);
319
320        return trim(Base64UrlSafeEncoder::encode(base64_decode($matches[1])));
321    }
322
323    protected function generateKey($outputDirectory)
324    {
325        $res = openssl_pkey_new(array(
326            "private_key_type" => OPENSSL_KEYTYPE_RSA,
327            "private_key_bits" => 4096,
328        ));
329
330        if(!openssl_pkey_export($res, $privateKey)) {
331            throw new \RuntimeException("Key export failed!");
332        }
333
334        $details = openssl_pkey_get_details($res);
335
336        if(!is_dir($outputDirectory)) @mkdir($outputDirectory, 0700, true);
337        if(!is_dir($outputDirectory)) throw new \RuntimeException("Cant't create directory $outputDirectory");
338
339        file_put_contents($outputDirectory.'/private.pem', $privateKey);
340        file_put_contents($outputDirectory.'/public.pem', $details['key']);
341    }
342
343    protected function signedRequest($uri, array $payload)
344    {
345        $privateKey = $this->readPrivateKey($this->accountKeyPath);
346        $details = openssl_pkey_get_details($privateKey);
347
348        $header = array(
349            "alg" => "RS256",
350            "jwk" => array(
351                "kty" => "RSA",
352                "n" => Base64UrlSafeEncoder::encode($details["rsa"]["n"]),
353                "e" => Base64UrlSafeEncoder::encode($details["rsa"]["e"]),
354            )
355        );
356
357        $protected = $header;
358        $protected["nonce"] = $this->client->getLastNonce();
359
360
361        $payload64 = Base64UrlSafeEncoder::encode(str_replace('\\/', '/', json_encode($payload)));
362        $protected64 = Base64UrlSafeEncoder::encode(json_encode($protected));
363
364        openssl_sign($protected64.'.'.$payload64, $signed, $privateKey, "SHA256");
365
366        $signed64 = Base64UrlSafeEncoder::encode($signed);
367
368        $data = array(
369            'header' => $header,
370            'protected' => $protected64,
371            'payload' => $payload64,
372            'signature' => $signed64
373        );
374
375        $this->log("Sending signed request to $uri");
376
377        return $this->client->post($uri, json_encode($data));
378    }
379
380    protected function log($message)
381    {
382        if($this->logger) {
383            $this->logger->info($message);
384        } else {
385            echo $message."\n";
386        }
387    }
388}
389
390interface ClientInterface
391{
392    /**
393     * Constructor
394     *
395     * @param string $base the ACME API base all relative requests are sent to
396     */
397    public function __construct($base);
398    /**
399     * Send a POST request
400     *
401     * @param string $url URL to post to
402     * @param array $data fields to sent via post
403     * @return array|string the parsed JSON response, raw response on error
404     */
405    public function post($url, $data);
406    /**
407     * @param string $url URL to request via get
408     * @return array|string the parsed JSON response, raw response on error
409     */
410    public function get($url);
411    /**
412     * Returns the Replay-Nonce header of the last request
413     *
414     * if no request has been made, yet. A GET on $base/directory is done and the
415     * resulting nonce returned
416     *
417     * @return mixed
418     */
419    public function getLastNonce();
420    /**
421     * Return the Location header of the last request
422     *
423     * returns null if last request had no location header
424     *
425     * @return string|null
426     */
427    public function getLastLocation();
428    /**
429     * Return the HTTP status code of the last request
430     *
431     * @return int
432     */
433    public function getLastCode();
434    /**
435     * Get all Link headers of the last request
436     *
437     * @return string[]
438     */
439    public function getLastLinks();
440}
441
442class Client implements ClientInterface
443{
444    protected $lastCode;
445    protected $lastHeader;
446
447    protected $base;
448
449    public function __construct($base)
450    {
451        $this->base = $base;
452    }
453
454    protected function curl($method, $url, $data = null)
455    {
456        $headers = array('Accept: application/json', 'Content-Type: application/json');
457        $handle = curl_init();
458        curl_setopt($handle, CURLOPT_URL, preg_match('~^http~', $url) ? $url : $this->base.$url);
459        curl_setopt($handle, CURLOPT_HTTPHEADER, $headers);
460        curl_setopt($handle, CURLOPT_RETURNTRANSFER, true);
461        curl_setopt($handle, CURLOPT_HEADER, true);
462
463        // DO NOT DO THAT!
464        // curl_setopt($handle, CURLOPT_SSL_VERIFYHOST, false);
465        // curl_setopt($handle, CURLOPT_SSL_VERIFYPEER, false);
466
467        switch ($method) {
468            case 'GET':
469                break;
470            case 'POST':
471                curl_setopt($handle, CURLOPT_POST, true);
472                curl_setopt($handle, CURLOPT_POSTFIELDS, $data);
473                break;
474        }
475        $response = curl_exec($handle);
476
477        if(curl_errno($handle)) {
478            throw new \RuntimeException('Curl: '.curl_error($handle));
479        }
480
481        $header_size = curl_getinfo($handle, CURLINFO_HEADER_SIZE);
482
483        $header = substr($response, 0, $header_size);
484        $body = substr($response, $header_size);
485
486        $this->lastHeader = $header;
487        $this->lastCode = curl_getinfo($handle, CURLINFO_HTTP_CODE);
488
489        $data = json_decode($body, true);
490        return $data === null ? $body : $data;
491    }
492
493    public function post($url, $data)
494    {
495        return $this->curl('POST', $url, $data);
496    }
497
498    public function get($url)
499    {
500        return $this->curl('GET', $url);
501    }
502
503    public function getLastNonce()
504    {
505        if(preg_match('~Replay\-Nonce: (.+)~i', $this->lastHeader, $matches)) {
506            return trim($matches[1]);
507        }
508
509        $this->curl('GET', '/directory');
510        return $this->getLastNonce();
511    }
512
513    public function getLastLocation()
514    {
515        if(preg_match('~Location: (.+)~i', $this->lastHeader, $matches)) {
516            return trim($matches[1]);
517        }
518        return null;
519    }
520
521    public function getLastCode()
522    {
523        return $this->lastCode;
524    }
525
526    public function getLastLinks()
527    {
528        preg_match_all('~Link: <(.+)>;rel="up"~', $this->lastHeader, $matches);
529        return $matches[1];
530    }
531}
532
533class Base64UrlSafeEncoder
534{
535    public static function encode($input)
536    {
537        return str_replace('=', '', strtr(base64_encode($input), '+/', '-_'));
538    }
539
540    public static function decode($input)
541    {
542        $remainder = strlen($input) % 4;
543        if ($remainder) {
544            $padlen = 4 - $remainder;
545            $input .= str_repeat('=', $padlen);
546        }
547        return base64_decode(strtr($input, '-_', '+/'));
548    }
549}
550