Skip to content

Instantly share code, notes, and snippets.

@md-riaz
Last active March 30, 2025 05:57
Show Gist options
  • Save md-riaz/7b4b269625aa46123f3bc483b6754b12 to your computer and use it in GitHub Desktop.
Save md-riaz/7b4b269625aa46123f3bc483b6754b12 to your computer and use it in GitHub Desktop.
Supports letsencrypt, buypass, zerossl providers
<?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