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($nonce)) { $response = $this->sendRequest($this->acmeDirectory["newNonce"], 204); if (!isset($nonce)) throw new Exception("get new nonce failed"); } $protected = [ "url" => $url, "alg" => "RS256", "nonce" => $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"); } $nonce = $this->findHeader($response, "replay-nonce", false); return $response; } public function dumpResponses($fileName) { $this->writeFile($this->dataDirectory . "/$fileName", implode("\n\n-----\n\n", array_reverse($this->responses)), 0600); } public function __construct($version, $dataDirectory, $code = null) { // *** SET VERSION *** $this->version = $version; // *** CREATE DATA DIRECTORY *** $this->createDirectory($dataDirectory, 0700); $this->dataDirectory = $dataDirectory; $filePath = $dataDirectory . "/code.txt"; try { if (isset($code)) { // *** CHECK CODE *** $correctCode = $this->readFile($filePath); if (!isset($correctCode)) throw new Exception("code.txt was missing"); if ($code !== $correctCode) throw new Exception("code was incorrect"); } } finally { // *** UPDATE CODE *** $this->writeFile($filePath, $this->encodeBase64(random_bytes(12)), 0600); } } public function execute($environment, $emailAddresses, $domainNames) { // *** ESTABLISH ENVIRONMENT *** switch ($environment) { case "production": $filePath = $this->dataDirectory . "/account.key"; $url = "https://acme-v02.api.letsencrypt.org/directory"; break; case "staging": $filePath = $this->dataDirectory . "/account-staging.key"; $url = "https://acme-staging-v02.api.letsencrypt.org/directory"; break; default: throw new Exception("unknown environment"); } // *** READ ACCOUNT KEY *** $this->accountKey = $this->readFile($filePath); $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"); $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($filePath, $this->accountKey, 0600); } $this->accountUrl = $this->findHeader($response, "location"); // *** UPDATE CONTACT *** $url = $this->accountUrl; $contact = []; foreach ($emailAddresses as $emailAddress) $contact[] = "mailto:$emailAddress"; $payload = [ "contact" => $contact ]; $response = $this->sendRequest($url, 200, $payload); // *** STOP IF NO DOMAIN NAMES SUBMITTED *** if (empty($domainNames)) return; // *** CREATE NEW ORDER *** $url = $this->acmeDirectory["newOrder"]; $identifiers = []; foreach ($domainNames as $domainName) $identifiers[] = [ "type" => "dns", "value" => $domainName ]; $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") continue; $challengeurls[] = $challenge["url"]; $challengetokens[] = $challenge["token"]; continue 2; } throw new Exception("no http-01 challenge found"); } // *** WRITE HTTP-01 CHALLENGE FILES *** $this->createDirectory("./.well-known", 0755); $this->createDirectory("./.well-known/acme-challenge", 0755); try { foreach ($challengetokens as $challengetoken) $this->writeFile("./.well-known/acme-challenge/$challengetoken", "$challengetoken.$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 failed"); } } 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" => $domainNames[0] ]; $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 ($domainNames as $domainName) { ++$i; $opensslcnf .= "DNS.$i = $domainName\n"; } try { $this->writeFile($this->dataDirectory . "/openssl.cnf", $opensslcnf, 0600); $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"]; // *** WRITE CERTIFICATE AND KEY *** $this->writeFile($this->dataDirectory . "/certificate.crt", $certificate, 0600); $this->writeFile($this->dataDirectory . "/certificate.key", $certificateKey, 0600); } } try { // *** PROCESS ACTION *** if (!isset($_POST["action"])) $action = ""; else { if (!is_string($_POST["action"])) throw new Exception("action was not a string"); $action = $_POST["action"]; } switch ($action) { case "": // *** CREATE DATA DIRECTORY AND UPDATE CODE *** $certsage = new CertSage($version, $dataDirectory); $page = "welcome"; break; case "proceed": // *** PROCESS CODE *** if (!isset($_POST["code"])) throw new Exception("code was missing"); if (!is_string($_POST["code"])) throw new Exception("code was not a string"); $code = $_POST["code"]; // *** CREATE DATA DIRECTORY, CHECK CODE, AND UPDATE CODE *** $certsage = new CertSage($version, $dataDirectory, $code); // *** PROCESS ENVIRONMENT *** if (!isset($_POST["environment"])) throw new Exception("environment was missing"); if (!is_string($_POST["environment"])) throw new Exception("environment was not a string"); $environment = $_POST["environment"]; // *** PROCESS EMAIL ADDRESSES *** if (!isset($_POST["emailAddresses"])) throw new Exception("emailAddresses was missing"); if (!is_string($_POST["emailAddresses"])) throw new Exception("emailAddresses was not a string"); $emailAddresses = []; for ($tok = strtok($_POST["emailAddresses"], "\r\n"); $tok !== false; $tok = strtok("\r\n")) { $tok = trim($tok); if (strlen($tok) == 0) continue; $emailAddresses[] = $tok; } // *** PROCESS DOMAIN NAMES *** if (!isset($_POST["domainNames"])) throw new Exception("domainNames was missing"); if (!is_string($_POST["domainNames"])) throw new Exception("domainNames was not a string"); $domainNames = []; for ($tok = strtok($_POST["domainNames"], "\r\n"); $tok !== false; $tok = strtok("\r\n")) { $tok = trim($tok); if (strlen($tok) == 0) continue; $domainNames[] = $tok; } // *** EXECUTE *** $certsage->execute($environment, $emailAddresses, $domainNames); $page = "success"; break; default: throw new Exception("unknown action"); } } catch (Exception $e) { $error = $e->getMessage(); $page = "trouble"; } finally { if (isset($certsage)) $certsage->dumpResponses("responses.txt"); } ?> CertSage
🧙🏼‍♂️ CertSage
version
support@griffin.software

Welcome!

CertSage is an ACME client that acquires free DV certificates from Let's Encrypt by satisfying an HTTP-01 challenge for each domain name to be covered by a certificate.


Contents of this file:




Please test using the staging environment
to avoid hitting the rate limits



Only for Let's Encrypt notifications
One per line



No wildcards (*)
One per line



By proceeding you are agreeing to the
Let's Encrypt Subscriber Agreement

Success!

If you submitted any fully qualified domain names, your new certificate and its key have been saved in .

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.

Click here to start over.

Your test using the staging environment was successful.

If you want to acquire a trusted certificate, please use the production environment.

Click here to start over.

Trouble...

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

Click here to start over.