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