version . " (support@griffin.software)")) throw new Exception("cURL set user agent option failed"); if (!curl_setopt($ch, CURLOPT_RETURNTRANSFER, true)) throw new Exception("cURL set return transfer option failed"); if (isset($payload)) { if (!isset($this->nonce)) { $response = $this->sendRequest($this->acmeDirectory["newNonce"], 204); if (!isset($this->nonce)) throw new Exception("get new nonce failed"); } $protected = [ "url" => $url, "alg" => "RS256", "nonce" => $this->nonce ]; if (isset($jwk)) $protected["jwk"] = $jwk; else $protected["kid"] = $this->accountUrl; $protected = $this->encodeBase64($this->encodeJSON($protected)); $payload = $payload === "" ? "" : $this->encodeBase64($this->encodeJSON($payload)); if (openssl_sign("$protected.$payload", $signature, $this->accountKey, "sha256WithRSAEncryption") === false) throw new Exception("openssl sign failed"); $signature = $this->encodeBase64($signature); $josejson = $this->encodeJSON([ "protected" => $protected, "payload" => $payload, "signature" => $signature ]); if (!curl_setopt($ch, CURLOPT_POSTFIELDS, $josejson)) throw new Exception("cURL set post fields option failed"); if (!curl_setopt($ch, CURLOPT_HTTPHEADER, ["Content-Type: application/jose+json", "Content-Length: " . strlen($josejson)])) throw new Exception("cURL set http header option failed"); } if (!curl_setopt($ch, CURLOPT_HEADER, true)) throw new Exception("cURL set header option failed"); if (!curl_setopt($ch, CURLOPT_HEADERFUNCTION, function($ch, $header) use (&$headers, &$headerSize) { $headers[] = $header; $length = strlen($header); $headerSize += $length; return $length; })) throw new Exception("cURL set header function option failed"); $body = curl_exec($ch); if ($body === false) throw new Exception("cURL execution failed: $url"); $this->responses[] = $body; $responseCode = curl_getinfo($ch, CURLINFO_RESPONSE_CODE); if ($responseCode === false) throw new Exception("cURL get response code info failed"); /* $headerSize = curl_getinfo($ch, CURLINFO_HEADER_SIZE); if ($headerSize === false) throw new Exception("cURL get header size info failed"); */ $length = strlen($body); if ($headerSize > $length) throw new Exception("improper response header size"); $body = $headerSize == $length ? "" : substr($body, $headerSize); if ($body === false) throw new Exception("could not truncate headers from response"); $response = [ "headers" => $headers, "body" => $body ]; if ($responseCode !== $expectedResponseCode) { if ($this->findHeader($response, "content-type", false) === "application/problem+json") { $problem = $this->decodeJSON($response["body"]); if (isset($problem["type"], $problem["detail"])) throw new Exception($problem["type"] . "
" . $problem["detail"]); } throw new Exception("unexpected response code: $responseCode vs $expectedResponseCode"); } $this->nonce = $this->findHeader($response, "replay-nonce", false); return $response; } public function initialize() { // *** CREATE DATA DIRECTORY *** $this->createDirectory($this->dataDirectory, 0755); // *** READ PASSWORD FILE *** $this->password = $this->readFile($this->dataDirectory . "/password.txt"); if (isset($this->password)) return; // *** GENERATE RANDOM PASSWORD *** $this->password = $this->encodeBase64(openssl_random_pseudo_bytes(15)); // *** WRITE PASSWORD FILE *** $this->writeFile($this->dataDirectory . "/password.txt", $this->password, 0644); } public function extractDomainNames() { // *** READ CERTIFICATE *** $certificate = $this->readFile($this->dataDirectory . "/certificate.crt"); if (!isset($certificate)) return ""; // *** EXTRACT CERTIFICATE *** $regex = "~^(-----BEGIN CERTIFICATE-----\n(?:[A-Za-z0-9+/]{64}\n)*(?:(?:[A-Za-z0-9+/]{4}){0,15}(?:[A-Za-z0-9+/]{2}(?:[A-Za-z0-9+/]|=)=)?\n)?-----END CERTIFICATE-----)~"; $outcome = preg_match($regex, $certificate, $matches); if ($outcome === false) throw new Exception("extract certificate failed"); if ($outcome === 0) throw new Exception("certificate format mismatch"); $certificate = $matches[1]; // *** CHECK CERTIFICATE *** $certificateObject = openssl_x509_read($certificate); if ($certificateObject === false) throw new Exception("check certificate failed"); // *** EXTRACT DOMAIN NAMES *** $certificateData = openssl_x509_parse($certificateObject); if ($certificateData === false) throw new Exception("parse certificate failed"); if (!isset($certificateData["extensions"]["subjectAltName"])) return ""; $sans = explode(", ", $certificateData["extensions"]["subjectAltName"]); foreach ($sans as &$san) { list($type, $value) = explode(":", $san); if ($type !== "DNS") return ""; $san = $value; } unset($san); return implode("\n", $sans); } public function checkPassword() { // *** CHECK PASSWORD *** if (!isset($_POST["password"])) throw new Exception("password was missing"); if (!is_string($_POST["password"])) throw new Exception("password was not a string"); if ($_POST["password"] !== $this->password) throw new Exception("password was incorrect"); } private function establishAccount() { // *** ESTABLISH ENVIRONMENT *** if (!isset($_POST["environment"])) throw new Exception("environment was missing"); if (!is_string($_POST["environment"])) throw new Exception("environment was not a string"); switch ($_POST["environment"]) { case "production": $fileName = "account.key"; $url = "https://acme-v02.api.letsencrypt.org/directory"; break; case "staging": $fileName = "account-staging.key"; $url = "https://acme-staging-v02.api.letsencrypt.org/directory"; break; default: throw new Exception("unknown environment: " . $_POST["environment"]); } // *** READ ACCOUNT KEY *** $this->accountKey = $this->readFile($this->dataDirectory . "/$fileName"); $accountKeyExists = isset($this->accountKey); if ($accountKeyExists) { // *** CHECK ACCOUNT KEY *** $accountKeyObject = openssl_pkey_get_private($this->accountKey); if ($accountKeyObject === false) throw new Exception("check account key failed"); } else { // *** GENERATE ACCOUNT KEY *** $options = [ "digest_alg" => "sha256", "private_key_bits" => 2048, "private_key_type" => OPENSSL_KEYTYPE_RSA ]; $accountKeyObject = openssl_pkey_new($options); if ($accountKeyObject === false) throw new Exception("generate account key failed"); if (!openssl_pkey_export($accountKeyObject, $this->accountKey)) throw new Exception("export account key failed"); } // *** GET ACCOUNT KEY DETAILS *** $accountKeyDetails = openssl_pkey_get_details($accountKeyObject); if ($accountKeyDetails === false) throw new Exception("get account key details failed"); // *** CONSTRUCT JWK *** $jwk = [ "e" => $this->encodeBase64($accountKeyDetails["rsa"]["e"]), // public exponent "kty" => "RSA", "n" => $this->encodeBase64($accountKeyDetails["rsa"]["n"]) // modulus ]; // *** CALCULATE THUMBPRINT *** $digest = openssl_digest($this->encodeJSON($jwk), "sha256", true); if ($digest === false) throw new Exception("digest JWK failed"); $this->thumbprint = $this->encodeBase64($digest); // *** GET ACME DIRECTORY *** $response = $this->sendRequest($url, 200); $this->acmeDirectory = $this->decodeJSON($response["body"]); if ($accountKeyExists) { // *** LOOKUP ACCOUNT *** $url = $this->acmeDirectory["newAccount"]; $payload = [ "onlyReturnExisting" => true ]; $response = $this->sendRequest($url, 200, $payload, $jwk); } else { // *** REGISTER ACCOUNT *** $url = $this->acmeDirectory["newAccount"]; $payload = [ "termsOfServiceAgreed" => true ]; $response = $this->sendRequest($url, 201, $payload, $jwk); // *** WRITE ACCOUNT KEY *** $this->writeFile($this->dataDirectory . "/$fileName", $this->accountKey, 0644); } $this->accountUrl = $this->findHeader($response, "location"); } private function dumpResponses() { $this->writeFile($this->dataDirectory . "/responses.txt", implode("\n\n-----\n\n", array_reverse($this->responses)), 0644); } public function acquireCertificate() { $this->responses = []; try { $this->establishAccount(); // *** CREATE NEW ORDER *** if (!isset($_POST["domainNames"])) throw new Exception("domainNames was missing"); if (!is_string($_POST["domainNames"])) throw new Exception("domainNames was not a string"); $identifiers = []; for ($domainName = strtok($_POST["domainNames"], "\r\n"); $domainName !== false; $domainName = strtok("\r\n")) $identifiers[] = [ "type" => "dns", "value" => $domainName ]; $url = $this->acmeDirectory["newOrder"]; $payload = [ "identifiers" => $identifiers ]; $response = $this->sendRequest($url, 201, $payload); $orderurl = $this->findHeader($response, "location"); $order = $this->decodeJSON($response["body"]); // *** GET CHALLENGES *** $authorizationurls = []; $challengeurls = []; $challengetokens = []; $payload = ""; // empty foreach ($order["authorizations"] as $url) { $response = $this->sendRequest($url, 200, $payload); $authorization = $this->decodeJSON($response["body"]); if ($authorization["status"] === "valid") continue; $authorizationurls[] = $url; foreach ($authorization["challenges"] as $challenge) { if ($challenge["type"] === "http-01") { $challengeurls[] = $challenge["url"]; $challengetokens[] = $challenge["token"]; continue 2; } } throw new Exception("no http-01 challenge found"); } // *** CREATE HTTP-01 CHALLENGE DIRECTORIES *** $this->createDirectory("./.well-known", 0755); $this->createDirectory("./.well-known/acme-challenge", 0755); try { // *** WRITE HTTP-01 CHALLENGE FILES *** foreach ($challengetokens as $challengetoken) $this->writeFile("./.well-known/acme-challenge/$challengetoken", "$challengetoken." . $this->thumbprint, 0644); // *** CONFIRM CHALLENGES *** sleep(2); $payload = (object)[]; // empty object foreach ($challengeurls as $url) $challenge = $this->sendRequest($url, 200, $payload); // *** CHECK AUTHORIZATIONS *** $payload = ""; // empty foreach ($authorizationurls as $url) { for ($attempt = 1; true; ++$attempt) { sleep(1); $response = $this->sendRequest($url, 200, $payload); $authorization = $this->decodeJSON($response["body"]); if ($authorization["status"] !== "pending") break; if ($attempt == 10) throw new Exception("authorization still pending after $attempt attempts"); } if ($authorization["status"] !== "valid") throw new Exception($authorization["challenges"][0]["error"]["type"] . "
" . $authorization["challenges"][0]["error"]["detail"]); } } finally { // *** DELETE HTTP-01 CHALLENGE FILES *** foreach ($challengetokens as $challengetoken) $this->deleteFile("./.well-known/acme-challenge/$challengetoken"); } // *** GENERATE CERTIFICATE KEY *** $options = [ "digest_alg" => "sha256", "private_key_bits" => 2048, "private_key_type" => OPENSSL_KEYTYPE_RSA ]; $certificateKeyObject = openssl_pkey_new($options); if ($certificateKeyObject === false) throw new Exception("generate certificate key failed"); if (!openssl_pkey_export($certificateKeyObject, $certificateKey)) throw new Exception("export certificate key failed"); // *** GENERATE CSR *** $dn = [ "commonName" => $identifiers[0]["value"] ]; $options = [ "digest_alg" => "sha256", "config" => $this->dataDirectory . "/openssl.cnf" ]; $opensslcnf = "[req]\n" . "distinguished_name = req_distinguished_name\n" . "req_extensions = v3_req\n\n" . "[req_distinguished_name]\n\n" . "[v3_req]\n" . "subjectAltName = @san\n\n" . "[san]\n"; $i = 0; foreach ($identifiers as $identifier) { ++$i; $opensslcnf .= "DNS.$i = " . $identifier["value"] . "\n"; } try { $this->writeFile($this->dataDirectory . "/openssl.cnf", $opensslcnf, 0644); $csrObject = openssl_csr_new($dn, $certificateKey, $options); if ($csrObject === false) throw new Exception("generate csr failed"); } finally { $this->deleteFile($this->dataDirectory . "/openssl.cnf"); } if (!openssl_csr_export($csrObject, $csr)) throw new Exception("export csr failed"); // *** FINALIZE ORDER *** $url = $order["finalize"]; $regex = "~^-----BEGIN CERTIFICATE REQUEST-----([A-Za-z0-9+/]+)=?=?-----END CERTIFICATE REQUEST-----$~"; $outcome = preg_match($regex, str_replace("\n", "", $csr), $matches); if ($outcome === false) throw new Exception("extract csr failed"); if ($outcome === 0) throw new Exception("csr format mismatch"); $payload = [ "csr" => strtr($matches[1], "+/", "-_") ]; $response = $this->sendRequest($url, 200, $payload); $order = $this->decodeJSON($response["body"]); if ($order["status"] !== "valid") { // *** CHECK ORDER *** $url = $orderurl; $payload = ""; // empty for ($attempt = 1; true; ++$attempt) { sleep(1); $response = $this->sendRequest($url, 200, $payload); $order = $this->decodeJSON($response["body"]); if (!( $order["status"] === "pending" || $order["status"] === "processing" || $order["status"] === "ready")) break; if ($attempt == 10) throw new Exception("order still pending after $attempt attempts"); } if ($order["status"] !== "valid") throw new Exception("order failed"); } // *** DOWNLOAD CERTIFICATE *** $url = $order["certificate"]; $payload = ""; // empty $response = $this->sendRequest($url, 200, $payload); $certificate = $response["body"]; if ($_POST["environment"] == "production") { // *** WRITE CERTIFICATE AND CERTIFICATE KEY *** $this->writeFile($this->dataDirectory . "/certificate.crt", $certificate, 0644); $this->writeFile($this->dataDirectory . "/certificate.key", $certificateKey, 0644); } } finally { $this->dumpResponses(); } } public function installCertificate() { // *** READ CERTIFICATE *** $certificate = $this->readFile($this->dataDirectory . "/certificate.crt"); if (!isset($certificate)) throw new Exception("certificate file does not exist"); // *** EXTRACT CERTIFICATE *** $regex = "~^(-----BEGIN CERTIFICATE-----\n(?:[A-Za-z0-9+/]{64}\n)*(?:(?:[A-Za-z0-9+/]{4}){0,15}(?:[A-Za-z0-9+/]{2}(?:[A-Za-z0-9+/]|=)=)?\n)?-----END CERTIFICATE-----)~"; $outcome = preg_match($regex, $certificate, $matches); if ($outcome === false) throw new Exception("extract certificate failed"); if ($outcome === 0) throw new Exception("certificate format mismatch"); $certificate = $matches[1]; // *** CHECK CERTIFICATE *** $certificateObject = openssl_x509_read($certificate); if ($certificateObject === false) throw new Exception("check certificate failed"); // *** READ CERTIFICATE KEY *** $certificateKey = $this->readFile($this->dataDirectory . "/certificate.key"); if (!isset($certificateKey)) throw new Exception("certificate key file does not exist"); // *** EXTRACT CERTIFICATE KEY *** $regex = "~^(-----BEGIN PRIVATE KEY-----\n(?:[A-Za-z0-9+/]{64}\n)*(?:(?:[A-Za-z0-9+/]{4}){0,15}(?:[A-Za-z0-9+/]{2}(?:[A-Za-z0-9+/]|=)=)?\n)?-----END PRIVATE KEY-----)~"; $outcome = preg_match($regex, $certificateKey, $matches); if ($outcome === false) throw new Exception("extract certificate key failed"); if ($outcome === 0) throw new Exception("certificate key format mismatch"); $certificateKey = $matches[1]; // *** CHECK CERTIFICATE KEY *** $certificateKeyObject = openssl_pkey_get_private($certificateKey); if ($certificateKeyObject === false) throw new Exception("check certificate key failed"); // *** VERIFY CERTIFICATE AND CERTIFICATE KEY CORRESPOND *** if (!openssl_x509_check_private_key($certificateObject, $certificateKeyObject)) throw new Exception("certificate and certificate key do not correspond"); // *** EXTRACT DOMAIN NAMES *** $certificateData = openssl_x509_parse($certificateObject); if ($certificateData === false) throw new Exception("parse certificate failed"); if (!isset($certificateData["extensions"]["subjectAltName"])) throw new Exception("No SANs found in certificate"); $sans = explode(", ", $certificateData["extensions"]["subjectAltName"]); foreach ($sans as &$san) { list($type, $value) = explode(":", $san); if ($type !== "DNS") throw new Exception("Non-DNS SAN found in certificate"); $san = $value; } unset($san); // *** INSTALL CERTIFICATE *** $domain = $sans[0]; $domainLength = strlen($sans[0]); foreach ($sans as $san) { $sanLength = strlen($san); if ($domainLength <= $sanLength) continue; $domain = $san; $domainLength = $sanLength; } $cert = rawurlencode($certificate); $key = rawurlencode($certificateKey); $output = `uapi SSL install_ssl domain=$domain cert=$cert key=$key --output=json`; // !!! need to test with shell turned off in cPanel if ($output === false) throw new Exception("shell execution pipe could not be established"); if (!isset($output)) throw new Exception("uapi SSL install_ssl failed"); $output = `uapi SSL toggle_ssl_redirect_for_domains domains=$domain state=1 --output=json`; if ($output === false) throw new Exception("shell execution pipe could not be established"); if (!isset($output)) throw new Exception("uapi SSL toggle_ssl_redirect_for_domains failed"); } public function updateContact() { $this->responses = []; try { $this->establishAccount(); // *** UPDATE CONTACT *** if (!isset($_POST["emailAddresses"])) throw new Exception("emailAddresses was missing"); if (!is_string($_POST["emailAddresses"])) throw new Exception("emailAddresses was not a string"); $contact = []; for ($emailAddress = strtok($_POST["emailAddresses"], "\r\n"); $emailAddress !== false; $emailAddress = strtok("\r\n")) $contact[] = "mailto:$emailAddress"; $url = $this->accountUrl; $payload = [ "contact" => $contact ]; $response = $this->sendRequest($url, 200, $payload); } finally { $this->dumpResponses(); } } } // *** MAIN *** try { $certsage = new CertSage(); $certsage->initialize(); if (!isset($_POST["action"])) { $domainNames = $certsage->extractDomainNames(); $page = "welcome"; } elseif (!is_string($_POST["action"])) throw new Exception("action was not a string"); else { $certsage->checkpassword(); switch ($_POST["action"]) { case "acquirecertificate": $certsage->acquireCertificate(); break; case "installcertificate": $certsage->installCertificate(); break; case "updatecontact": $certsage->updateContact(); break; default: throw new Exception("unknown action: " . $_POST["action"]); } $page = "success"; } } catch (Exception $e) { $error = $e->getMessage(); $page = "trouble"; } ?> CertSage
🧙🏼‍♂️ CertSage
version version ?>
support@griffin.software

Welcome!

CertSage is an ACME client that acquires free DV TLS/SSL certificates from Let's Encrypt by satisfying an HTTP-01 challenge for each domain name to be covered by a certificate. By using CertSage, you are agreeing to the Let's Encrypt Subscriber Agreement. Please use the staging environment for testing to avoid hitting the rate limits.


Acquire Certificate

One domain name per line
No wildcards (*) allowed

Password
Contents of dataDirectory ?>/password.txt


Install Certificate into cPanel

Password
Contents of dataDirectory ?>/password.txt


Receive Certificate Expiration Notifications

One email address per line
Leave blank to unsubscribe

Password
Contents of dataDirectory ?>/password.txt

Success!

Your staging certificate was acquired. It was not saved to prevent accidental installation.

Your likely next step is to go back to the beginning to acquire your production certificate.

If you like free and easy certificates, please consider donating to CertSage and Let's Encrypt using the links at the bottom of this page.

Go back to the beginning

Success!

Your production certificate was acquired. It was saved in dataDirectory ?>.

Your likely next step is to go back to the beginning to install your certificate into cPanel.

If you like free and easy certificates, please consider donating to CertSage and Let's Encrypt using the links at the bottom of this page.

Go back to the beginning

Success!

Your certificate was installed into cPanel.

Your likely next step is to go back to the beginning to update your contact information.

If you like free and easy certificates, please consider donating to CertSage and Let's Encrypt using the links at the bottom of this page.

Go back to the beginning

Success!

Your contact information was updated.

You are likely good to go.

If you like free and easy certificates, please consider donating to CertSage and Let's Encrypt using the links at the bottom of this page.

Go back to the beginning

Trouble...

If you need help with resolving this issue, please post a topic in the help category of the Let's Encrypt Community.

Go back to the beginning