Last active
March 30, 2025 05:57
-
-
Save md-riaz/7b4b269625aa46123f3bc483b6754b12 to your computer and use it in GitHub Desktop.
Supports letsencrypt, buypass, zerossl providers
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
<?php | |
ini_set('display_errors', 1); | |
ini_set('display_startup_errors', 1); | |
error_reporting(E_ALL); | |
class AcmeClient | |
{ | |
private $webDir; | |
private $domain; | |
private $accountEmail; | |
private $provider; | |
private $staging; | |
private $acmeDirectoryUrl; | |
private $accountKeyPath; | |
private $domainKeyPath; | |
private $certPath; | |
private $directory; | |
private $accountKey; | |
private $accountUrl; | |
/** | |
* Constructor to initialize the ACME client with required parameters. | |
*/ | |
public function __construct($webDir, $domain, $accountEmail, $provider = 'letsencrypt', $staging = true) | |
{ | |
$this->webDir = rtrim($webDir, '/'); | |
$this->domain = $domain; | |
$this->accountEmail = $accountEmail; | |
$this->provider = $provider; | |
$this->staging = $staging; | |
// Set ACME directory URL based on provider and staging flag | |
$this->acmeDirectoryUrl = $this->getProviderDirectoryUrl(); | |
$this->accountKeyPath = __DIR__ . '/account.key'; | |
$this->domainKeyPath = __DIR__ . '/domain_' . $this->domain . '.key'; | |
$this->certPath = __DIR__ . '/cert_' . $this->domain . '.pem'; | |
if (!is_dir($this->webDir) || !is_writable($this->webDir)) { | |
die("Web directory is not writable: " . $this->webDir . "\n"); | |
} | |
} | |
/** | |
* Get the ACME directory URL based on the provider and staging flag. | |
*/ | |
private function getProviderDirectoryUrl() | |
{ | |
$urls = [ | |
'letsencrypt' => [ | |
'staging' => 'https://acme-staging-v02.api.letsencrypt.org/directory', | |
'production' => 'https://acme-v02.api.letsencrypt.org/directory' | |
], | |
'buypass' => [ | |
'staging' => 'https://api.test4.buypass.no/acme/directory', | |
'production' => 'https://api.buypass.com/acme/directory' | |
], | |
'zerossl' => [ | |
'staging' => 'https://acme-staging.zerossl.com/v2/DV90', | |
'production' => 'https://acme.zerossl.com/v2/DV90' | |
], | |
'google' => [ | |
'staging' => 'https://acme-staging.googletrust.com/directory', // Placeholder URL | |
'production' => 'https://acme.googletrust.com/directory' // Placeholder URL | |
] | |
]; | |
if (!isset($urls[$this->provider])) { | |
die("Unsupported provider: " . $this->provider . "\n"); | |
} | |
return $this->staging ? $urls[$this->provider]['staging'] : $urls[$this->provider]['production']; | |
} | |
/** | |
* Main method to execute the ACME client workflow. | |
*/ | |
public function run() | |
{ | |
$this->loadOrGenerateAccountKey(); | |
$this->fetchAcmeDirectory(); | |
$this->registerAccount(); | |
$this->generateDomainKey(); | |
$csrDer = $this->createCSR(); | |
$orderUrl = $this->createOrder(); | |
$this->handleAuthorizations($orderUrl); | |
$this->finalizeOrder($orderUrl, $csrDer); | |
} | |
/** | |
* Load the account key from file or generate a new one if it doesn't exist. | |
*/ | |
private function loadOrGenerateAccountKey() | |
{ | |
if (!file_exists($this->accountKeyPath)) { | |
$this->generatePrivateKey($this->accountKeyPath); | |
} | |
$this->accountKey = $this->loadPrivateKey($this->accountKeyPath); | |
} | |
/** | |
* Fetch the ACME directory to retrieve endpoint URLs. | |
*/ | |
private function fetchAcmeDirectory() | |
{ | |
$response = $this->httpRequest($this->acmeDirectoryUrl); | |
$this->directory = json_decode($response['body'], true); | |
} | |
/** | |
* Register a new account with the ACME server, including EAB if required. | |
*/ | |
private function registerAccount() | |
{ | |
$nonce = $this->getNewNonce($this->directory['newNonce']); | |
$protected = [ | |
'alg' => 'RS256', | |
'jwk' => $this->getJWK($this->accountKey), | |
'url' => $this->directory['newAccount'], | |
'nonce' => $nonce | |
]; | |
$payload = [ | |
'termsOfServiceAgreed' => true, | |
'contact' => ["mailto:" . $this->accountEmail] | |
]; | |
// Add EAB (External Account Binding) if required by the provider | |
if ($this->provider === 'zerossl') { | |
$eabCredentials = $this->fetchEabCredentials(); | |
$eabProtected = $this->base64UrlEncode(json_encode(['alg' => 'HS256', 'kid' => $eabCredentials['key_id']])); | |
$eabPayload = $this->base64UrlEncode($this->getJWK($this->accountKey)); | |
$eabSignature = hash_hmac('sha256', "$eabProtected.$eabPayload", base64_decode($eabCredentials['hmac_key']), true); | |
$eabSignatureEncoded = $this->base64UrlEncode($eabSignature); | |
$payload['externalAccountBinding'] = [ | |
'protected' => $eabProtected, | |
'payload' => $eabPayload, | |
'signature' => $eabSignatureEncoded | |
]; | |
} | |
$signed = $this->signJWS($this->accountKey, json_encode($payload), $protected, $this->directory['newAccount']); | |
$response = $this->httpRequest($this->directory['newAccount'], 'POST', $signed); | |
if ($response['code'] == 201 || $response['code'] == 200) { | |
$this->accountUrl = $response['headers']['location'] ?? die("No account URL returned\n"); | |
} else { | |
die("Failed to register account: " . $response['body'] . "\n"); | |
} | |
} | |
/** | |
* Fetch EAB credentials from ZeroSSL using the contact email. | |
*/ | |
private function fetchEabCredentials() | |
{ | |
$url = 'https://api.zerossl.com/acme/eab-credentials-email'; | |
$response = $this->httpRequest($url, 'POST', json_encode(['email' => $this->accountEmail])); | |
if ($response['code'] !== 200) { | |
die("Failed to fetch EAB credentials: " . $response['body'] . "\n"); | |
} | |
$data = json_decode($response['body'], true); | |
if (!isset($data['key_id'], $data['hmac_key'])) { | |
die("Invalid EAB credentials response\n"); | |
} | |
return $data; | |
} | |
/** | |
* Generate a private key for the domain if it doesn't exist. | |
*/ | |
private function generateDomainKey() | |
{ | |
if (!file_exists($this->domainKeyPath)) { | |
$this->generatePrivateKey($this->domainKeyPath); | |
} | |
} | |
/** | |
* Get the thumbprint for the account key. | |
*/ | |
private function getThumbprint($key) | |
{ | |
$jwk = $this->getJWK($key); | |
ksort($jwk); // Ensure canonical order | |
$json = json_encode($jwk, JSON_UNESCAPED_SLASHES); | |
$hash = hash('sha256', $json, true); | |
return $this->base64UrlEncode($hash); | |
} | |
/** | |
* Create a Certificate Signing Request (CSR) for the domain. | |
*/ | |
private function createCSR() | |
{ | |
$domainKey = $this->loadPrivateKey($this->domainKeyPath); | |
$csrPem = $this->createCSRForDomain([$this->domain], $domainKey); | |
return $this->pemToDer($csrPem); | |
} | |
/** | |
* Create a new order for the domain certificate. | |
*/ | |
private function createOrder() | |
{ | |
$nonce = $this->getNewNonce($this->directory['newNonce']); | |
$protected = [ | |
'alg' => 'RS256', | |
'kid' => $this->accountUrl, | |
'url' => $this->directory['newOrder'], | |
'nonce' => $nonce | |
]; | |
$payload = json_encode(['identifiers' => [['type' => 'dns', 'value' => $this->domain]]]); | |
$signed = $this->signJWS($this->accountKey, $payload, $protected, $this->directory['newOrder']); | |
$response = $this->httpRequest($this->directory['newOrder'], 'POST', $signed); | |
if ($response['code'] != 201) { | |
die("Failed to create order: " . $response['body'] . "\n"); | |
} | |
$order = json_decode($response['body'], true); | |
return $response['headers']['location']; | |
} | |
/** | |
* Handle the authorization challenges for the order. | |
*/ | |
private function handleAuthorizations($orderUrl) | |
{ | |
$response = $this->httpRequest($orderUrl); | |
$order = json_decode($response['body'], true); | |
foreach ($order['authorizations'] as $authUrl) { | |
$authResponse = $this->httpRequest($authUrl); | |
$auth = json_decode($authResponse['body'], true); | |
foreach ($auth['challenges'] as $challenge) { | |
if ($challenge['type'] === 'http-01') { | |
$this->createChallenge($challenge); | |
$this->verifyChallenge($challenge); | |
} | |
} | |
} | |
} | |
/** | |
* Create the HTTP-01 challenge file. | |
*/ | |
private function createChallenge($challenge) | |
{ | |
$token = $challenge['token']; | |
$keyAuth = $token . '.' . $this->getThumbprint($this->accountKey); | |
$challengeDir = $this->webDir . '/.well-known/acme-challenge'; | |
if (!is_dir($challengeDir)) { | |
mkdir($challengeDir, 0755, true); | |
} | |
$challengeFile = "$challengeDir/$token"; | |
file_put_contents($challengeFile, $keyAuth); | |
echo "Challenge file created: $challengeFile\n"; | |
} | |
/** | |
* Verify the HTTP-01 challenge by notifying the ACME server. | |
*/ | |
private function verifyChallenge($challenge) | |
{ | |
$nonce = $this->getNewNonce($this->directory['newNonce']); | |
$protected = [ | |
'alg' => 'RS256', | |
'kid' => $this->accountUrl, | |
'url' => $challenge['url'], | |
'nonce' => $nonce | |
]; | |
$signed = $this->signJWS($this->accountKey, '{}', $protected, $challenge['url']); | |
$response = $this->httpRequest($challenge['url'], 'POST', $signed); | |
if ($response['code'] >= 400) { | |
die("Challenge verification failed: " . $response['body'] . "\n"); | |
} | |
echo "Challenge verified successfully.\n"; | |
} | |
/** | |
* Finalize the order and retrieve the certificate. | |
*/ | |
private function finalizeOrder($orderUrl, $csrDer) | |
{ | |
$nonce = $this->getNewNonce($this->directory['newNonce']); | |
$protected = [ | |
'alg' => 'RS256', | |
'kid' => $this->accountUrl, | |
'url' => $orderUrl, | |
'nonce' => $nonce | |
]; | |
$payload = json_encode(['csr' => $csrDer]); | |
$signed = $this->signJWS($this->accountKey, $payload, $protected, $orderUrl); | |
$response = $this->httpRequest($orderUrl, 'POST', $signed); | |
$order = json_decode($response['body'], true); | |
// Poll the order status until it is valid or fails | |
while (in_array($order['status'], ['processing', 'ready'])) { | |
sleep(2); | |
$response = $this->httpRequest($orderUrl); | |
$order = json_decode($response['body'], true); | |
} | |
if ($order['status'] === 'valid') { | |
$certUrl = $order['certificate']; | |
$certResponse = $this->httpRequest($certUrl); | |
file_put_contents($this->certPath, $certResponse['body']); | |
echo "Certificate saved to " . $this->certPath . "\n"; | |
} else { | |
die("Order failed with status: " . $order['status'] . "\n"); | |
} | |
} | |
/** | |
* Generate a new private key and save it to a file. | |
*/ | |
private function generatePrivateKey($path) | |
{ | |
$key = openssl_pkey_new(['private_key_bits' => 2048, 'private_key_type' => OPENSSL_KEYTYPE_RSA]); | |
openssl_pkey_export_to_file($key, $path); | |
} | |
/** | |
* Load a private key from a file. | |
*/ | |
private function loadPrivateKey($path) | |
{ | |
return openssl_pkey_get_private(file_get_contents($path)); | |
} | |
/** | |
* Create a CSR for the given domain(s) using the private key. | |
*/ | |
private function createCSRForDomain($domains, $key) | |
{ | |
$dn = ['commonName' => $domains[0]]; | |
$csr = openssl_csr_new($dn, $key); | |
openssl_csr_export($csr, $csrPem); | |
return $csrPem; | |
} | |
/** | |
* Convert a PEM-formatted string to DER format. | |
*/ | |
private function pemToDer($pem) | |
{ | |
$lines = explode("\n", $pem); | |
$data = ''; | |
foreach ($lines as $line) { | |
if ($line[0] !== '-' && trim($line) !== '') { | |
$data .= $line; | |
} | |
} | |
return base64_decode($data); | |
} | |
/** | |
* Get the JSON Web Key (JWK) representation of a private key. | |
*/ | |
private function getJWK($key) | |
{ | |
$details = openssl_pkey_get_details($key); | |
$modulus = $this->base64UrlEncode($details['rsa']['n']); | |
$exponent = $this->base64UrlEncode($details['rsa']['e']); | |
return ['kty' => 'RSA', 'n' => $modulus, 'e' => $exponent]; | |
} | |
/** | |
* Sign a payload to create a JSON Web Signature (JWS). | |
*/ | |
private function signJWS($key, $payload, $protected, $url) | |
{ | |
$protectedEncoded = $this->base64UrlEncode(json_encode($protected)); | |
$payloadEncoded = $this->base64UrlEncode($payload); | |
$signatureInput = $protectedEncoded . '.' . $payloadEncoded; | |
openssl_sign($signatureInput, $signature, $key, OPENSSL_ALGO_SHA256); | |
$signatureEncoded = $this->base64UrlEncode($signature); | |
return json_encode(['protected' => $protectedEncoded, 'payload' => $payloadEncoded, 'signature' => $signatureEncoded]); | |
} | |
/** | |
* Get a new nonce from the ACME server. | |
*/ | |
private function getNewNonce($newNonceUrl) | |
{ | |
$response = $this->httpRequest($newNonceUrl); | |
return $response['headers']['replay-nonce'] ?? die("Failed to get new nonce\n"); | |
} | |
/** | |
* Send an HTTP request to the ACME server. | |
*/ | |
private function httpRequest($url, $method = 'GET', $data = null) | |
{ | |
$ch = curl_init(); | |
curl_setopt($ch, CURLOPT_URL, $url); | |
curl_setopt($ch, CURLOPT_RETURNTRANSFER, true); | |
curl_setopt($ch, CURLOPT_HEADER, true); | |
curl_setopt($ch, CURLOPT_CUSTOMREQUEST, $method); | |
if ($data) { | |
curl_setopt($ch, CURLOPT_POSTFIELDS, $data); | |
curl_setopt($ch, CURLOPT_HTTPHEADER, ['Content-Type: application/jose+json']); | |
} | |
$response = curl_exec($ch); | |
$headerSize = curl_getinfo($ch, CURLINFO_HEADER_SIZE); | |
$headers = substr($response, 0, $headerSize); | |
$body = substr($response, $headerSize); | |
curl_close($ch); | |
$headerLines = explode("\r\n", $headers); | |
$headerAssoc = []; | |
foreach ($headerLines as $line) { | |
if (strpos($line, ':') !== false) { | |
list($key, $value) = explode(':', $line, 2); | |
$headerAssoc[trim($key)] = trim($value); | |
} | |
} | |
return ['headers' => $headerAssoc, 'body' => $body, 'code' => curl_getinfo($ch, CURLINFO_HTTP_CODE)]; | |
} | |
/** | |
* Base64 URL encode data. | |
*/ | |
private function base64UrlEncode($data) | |
{ | |
return rtrim(strtr(base64_encode($data), '+/', '-_'), '='); | |
} | |
} | |
// Instantiate and run the ACME client | |
$webDir = $_GET['dir']; | |
$domain = $_GET['domain']; | |
$accountEmail = '[email protected]'; // Replace with your email | |
$provider = $_GET['provider'] ?? 'letsencrypt'; // Default to Let's Encrypt | |
$staging = isset($_GET['staging']) ? filter_var($_GET['staging'], FILTER_VALIDATE_BOOLEAN) : true; | |
$acmeClient = new AcmeClient($webDir, $domain, $accountEmail, $provider, $staging); | |
$acmeClient->run(); |
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment