<?php
/**
* SeekQuarry/Yioop --
* Open Source Pure PHP Search Engine, Crawler, and Indexer
*
* Copyright (C) 2009 - 2026 Chris Pollett chris@pollett.org
*
* LICENSE:
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU General Public License for more details.
*
* You should have received a copy of the GNU General Public License
* along with this program. If not, see
* <https://www.gnu.org/licenses/>.
*
* END LICENSE
*
* @author Chris Pollett chris@pollett.org
* @license https://www.gnu.org/licenses/ GPL3
* @link https://www.seekquarry.com/
* @copyright 2009 - 2026
* @filesource
*/
namespace seekquarry\yioop\library;
/**
* Minimal ACMEv2 (RFC 8555) client used to obtain and renew TLS
* certificates for a Yioop site's principal domain and a list of
* subdomains (for example www and mta-sts) on one multi-SAN
* certificate. It speaks the JWS-signed JSON the protocol uses and
* supports the HTTP-01 challenge, which a Yioop WebSite answers with
* a single route, and the ACME Renewal Information (ARI) endpoint so
* a renewal job can let the certificate authority pick the renewal
* window rather than guessing from a fixed age threshold.
*
* The account key signs every request and is the credential for the
* ACME account; it is generated and stored separately from the
* certificate keys. An RSA account key (RS256) is used so request
* signing is a single openssl_sign with no JOSE signature-format
* conversion. This class does its own small HTTP because the
* protocol needs response headers (Replay-Nonce, Location), must not
* follow redirects on those, and must read 4xx bodies (a bad-nonce
* 400 is a retry signal, not a hard error) -- the opposite of what
* the crawler fetcher does.
*
* @author Chris Pollett chris@pollett.org
*/
class AcmeClient
{
/**
* Directory URL of the ACME server (the one document from which
* every other endpoint is discovered).
* @var string
*/
private $directory_url;
/**
* PEM-encoded RSA account private key used to sign requests.
* @var string
*/
private $account_key_pem;
/**
* OpenSSL key resource for the account key, lazily created from
* the PEM.
* @var \OpenSSLAsymmetricKey|null
*/
private $account_key;
/**
* The account URL ("kid") returned by newAccount, sent in the
* protected header of every subsequent request.
* @var string|null
*/
private $account_url;
/**
* Endpoint URLs discovered from the directory document keyed by
* ACME resource name (newNonce, newAccount, newOrder, revokeCert,
* keyChange, and optionally renewalInfo).
* @var array
*/
private $directory;
/**
* Most recent Replay-Nonce. Each signed POST consumes one and
* the response supplies the next, so this is refreshed from
* every response.
* @var string|null
*/
private $nonce;
/**
* Seconds to allow each HTTP request before giving up.
* @var int
*/
private $timeout;
/**
* Public-key directory endpoint name for ARI in the directory
* document. ARI is optional; absent on servers that predate it.
*/
const RENEWAL_INFO = "renewalInfo";
/**
* @param string $directory_url ACME directory URL (use the
* staging directory while developing)
* @param string $account_key_pem PEM of the RSA account private
* key; generate one with generateAccountKey if starting
* fresh
* @param int $timeout per-request timeout in seconds
*/
public function __construct($directory_url, $account_key_pem,
$timeout = 30)
{
$this->directory_url = $directory_url;
$this->account_key_pem = $account_key_pem;
$this->account_key = null;
$this->account_url = null;
$this->directory = [];
$this->nonce = null;
$this->timeout = $timeout;
}
/**
* Generates a fresh RSA account private key and returns it as a
* PEM string. Stored once and reused for the lifetime of the
* ACME account.
*
* @param int $bits RSA modulus size
* @return string PEM-encoded private key
*/
public static function generateAccountKey($bits = 2048)
{
$key = openssl_pkey_new([
"private_key_bits" => $bits,
"private_key_type" => OPENSSL_KEYTYPE_RSA,
]);
$pem = "";
openssl_pkey_export($key, $pem);
return $pem;
}
/**
* URL-safe base64 with padding stripped, as JOSE and ACME use
* everywhere (tokens, JWS segments, thumbprints).
*
* @param string $data raw bytes
* @return string base64url text without '=' padding
*/
public static function base64url($data)
{
return rtrim(strtr(base64_encode($data), "+/", "-_"), "=");
}
/**
* Returns the account key as an OpenSSL key resource, parsing
* the stored PEM on first use.
*
* @return \OpenSSLAsymmetricKey the account private key
*/
private function accountKey()
{
if ($this->account_key === null) {
$this->account_key =
openssl_pkey_get_private($this->account_key_pem);
}
return $this->account_key;
}
/**
* Builds the public JWK for the RSA account key: the modulus and
* exponent as base64url big-endian integers, with members in the
* lexicographic order RFC 7638 requires for a stable thumbprint.
*
* @return array JWK with keys 'e', 'kty', 'n' in that order
*/
public function jwk()
{
$details = openssl_pkey_get_details($this->accountKey());
return [
"e" => self::base64url($details["rsa"]["e"]),
"kty" => "RSA",
"n" => self::base64url($details["rsa"]["n"]),
];
}
/**
* RFC 7638 JWK thumbprint: SHA-256 over the JWK serialized as
* compact JSON with members lexicographically ordered and no
* extra whitespace. Used to form the key authorization a
* challenge proves.
*
* @return string base64url of the SHA-256 thumbprint
*/
public function jwkThumbprint()
{
$jwk = $this->jwk();
$canonical = '{"e":"' . $jwk["e"] . '","kty":"' . $jwk["kty"]
. '","n":"' . $jwk["n"] . '"}';
return self::base64url(hash("sha256", $canonical, true));
}
/**
* Key authorization for a challenge token: the token, a dot, and
* the account key's JWK thumbprint. For HTTP-01 this exact
* string is what the .well-known/acme-challenge/<token> route
* must return.
*
* @param string $token challenge token from the authorization
* @return string the key authorization string
*/
public function keyAuthorization($token)
{
return $token . "." . $this->jwkThumbprint();
}
/**
* Signs a request payload as a JWS in the flattened JSON
* serialization ACME expects. The protected header carries the
* algorithm (RS256), the nonce, the request URL, and either the
* account URL (kid) once registered or the full JWK before
* then. A null payload serializes as the empty string, which
* ACME uses for POST-as-GET reads.
*
* @param string $url request URL, echoed in the protected header
* @param array|null $payload request body, or null for
* POST-as-GET
* @return string JSON-encoded JWS ready to POST
*/
private function signedRequest($url, $payload)
{
$protected = [
"alg" => "RS256",
"nonce" => $this->nonce,
"url" => $url,
];
if ($this->account_url !== null) {
$protected["kid"] = $this->account_url;
} else {
$protected["jwk"] = $this->jwk();
}
$protected64 = self::base64url(json_encode($protected));
/* A null payload is the POST-as-GET empty string. Any other
payload is a JSON object; an empty PHP array must still
encode as the object {} (json_encode would otherwise emit
the array []), so empty arrays are forced to an object. */
if ($payload === null) {
$payload64 = "";
} else {
if (is_array($payload) && empty($payload)) {
$payload = new \stdClass();
}
$payload64 = self::base64url(json_encode($payload));
}
$signing_input = $protected64 . "." . $payload64;
$signature = "";
openssl_sign($signing_input, $signature, $this->accountKey(),
OPENSSL_ALGO_SHA256);
return json_encode([
"protected" => $protected64,
"payload" => $payload64,
"signature" => self::base64url($signature),
]);
}
/**
* Performs one HTTP request and returns status, headers, and
* body. Unlike the crawler fetcher this reads response headers
* (needed for Replay-Nonce and Location), does not follow
* redirects (ACME's Location is data, not a redirect), does not
* fail on 4xx (a bad-nonce 400 is a retry signal), and verifies
* the peer certificate.
*
* @param string $url request URL
* @param string|null $body request body for POST, or null for a
* plain GET
* @param array $extra_headers extra request headers as
* "Name: value" strings
* @return array ['status' => int, 'headers' => array,
* 'body' => string]
*/
private function http($url, $body = null, $extra_headers = [])
{
$curl = curl_init();
curl_setopt($curl, CURLOPT_URL, $url);
curl_setopt($curl, CURLOPT_RETURNTRANSFER, true);
curl_setopt($curl, CURLOPT_HEADER, true);
curl_setopt($curl, CURLOPT_FOLLOWLOCATION, false);
curl_setopt($curl, CURLOPT_SSL_VERIFYPEER, true);
curl_setopt($curl, CURLOPT_SSL_VERIFYHOST, 2);
curl_setopt($curl, CURLOPT_TIMEOUT, $this->timeout);
curl_setopt($curl, CURLOPT_CONNECTTIMEOUT, $this->timeout);
$headers = $extra_headers;
if ($body !== null) {
curl_setopt($curl, CURLOPT_POST, true);
curl_setopt($curl, CURLOPT_POSTFIELDS, $body);
$headers[] = "Content-Type: application/jose+json";
}
if (!empty($headers)) {
curl_setopt($curl, CURLOPT_HTTPHEADER, $headers);
}
$response = curl_exec($curl);
$status = curl_getinfo($curl, CURLINFO_HTTP_CODE);
$header_size = curl_getinfo($curl, CURLINFO_HEADER_SIZE);
curl_close($curl);
$raw_headers = substr($response, 0, $header_size);
$body_text = substr($response, $header_size);
return [
"status" => $status,
"headers" => $this->parseHeaders($raw_headers),
"body" => $body_text,
];
}
/**
* Parses a raw HTTP response header block into a lowercased-key
* map. Repeated headers keep the last value, which is sufficient
* for the single-valued headers ACME uses (Replay-Nonce,
* Location, Content-Type, Retry-After).
*
* @param string $raw raw header block, possibly including the
* status line and earlier redirect blocks
* @return array map of lowercased header name to value
*/
private function parseHeaders($raw)
{
$headers = [];
$lines = preg_split('/\r\n|\n/', trim($raw));
foreach ($lines as $line) {
$colon = strpos($line, ":");
if ($colon === false) {
continue;
}
$name = strtolower(trim(substr($line, 0, $colon)));
$value = trim(substr($line, $colon + 1));
$headers[$name] = $value;
}
return $headers;
}
/**
* Fetches the directory document and caches the endpoint URLs.
* Also primes the first nonce.
*
* @return void
*/
public function fetchDirectory()
{
$response = $this->http($this->directory_url);
$this->directory = json_decode($response["body"], true) ?? [];
$this->refreshNonce();
}
/**
* Gets a fresh nonce from the newNonce endpoint and stores it.
*
* @return void
*/
public function refreshNonce()
{
$url = $this->directory["newNonce"] ?? null;
if ($url === null) {
return;
}
$response = $this->http($url);
if (isset($response["headers"]["replay-nonce"])) {
$this->nonce = $response["headers"]["replay-nonce"];
}
}
/**
* Sends a signed POST to $url with $payload (null for a
* POST-as-GET read), refreshing the stored nonce from the
* response. On a badNonce error it retries once with a fresh
* nonce, the recovery the protocol prescribes.
*
* @param string $url endpoint URL
* @param array|null $payload request body or null
* @return array the http() result
*/
private function post($url, $payload)
{
$response = $this->http($url, $this->signedRequest($url,
$payload));
if (isset($response["headers"]["replay-nonce"])) {
$this->nonce = $response["headers"]["replay-nonce"];
}
if ($response["status"] === 400 &&
strpos($response["body"], "badNonce") !== false) {
$response = $this->http($url, $this->signedRequest($url,
$payload));
if (isset($response["headers"]["replay-nonce"])) {
$this->nonce = $response["headers"]["replay-nonce"];
}
}
return $response;
}
/**
* Registers (or looks up) the ACME account for the stored key,
* agreeing to the terms of service, and remembers the account
* URL used as the kid on later requests.
*
* @param string $contact_email contact address for the account
* @return bool true if the account URL was obtained
*/
public function registerAccount($contact_email)
{
$url = $this->directory["newAccount"] ?? null;
if ($url === null) {
return false;
}
$payload = [
"termsOfServiceAgreed" => true,
"contact" => ["mailto:" . $contact_email],
];
$response = $this->post($url, $payload);
if (isset($response["headers"]["location"])) {
$this->account_url = $response["headers"]["location"];
return true;
}
return false;
}
/**
* Creates a new order for the given identifiers (the principal
* domain and any subdomains). Returns the decoded order object,
* with its URL added under the 'url' key from the Location
* header so later finalize and poll calls can reference it.
*
* @param array $domains DNS names to include on the certificate
* @return array|null the order, or null on failure
*/
public function newOrder($domains)
{
$url = $this->directory["newOrder"] ?? null;
if ($url === null) {
return null;
}
$identifiers = [];
foreach ($domains as $domain) {
$identifiers[] = ["type" => "dns", "value" => $domain];
}
$response = $this->post($url, ["identifiers" => $identifiers]);
$order = json_decode($response["body"], true);
if (!is_array($order)) {
return null;
}
if (isset($response["headers"]["location"])) {
$order["url"] = $response["headers"]["location"];
}
return $order;
}
/**
* Reads one authorization object (POST-as-GET).
*
* @param string $authz_url authorization URL from the order
* @return array|null decoded authorization, or null
*/
public function authorization($authz_url)
{
$response = $this->post($authz_url, null);
return json_decode($response["body"], true);
}
/**
* Extracts the HTTP-01 challenge from an authorization object.
*
* @param array $authorization decoded authorization
* @return array|null the http-01 challenge, or null if absent
*/
public function httpChallenge($authorization)
{
foreach ($authorization["challenges"] ?? [] as $challenge) {
if (($challenge["type"] ?? "") === "http-01") {
return $challenge;
}
}
return null;
}
/**
* Tells the server a challenge is ready to be validated by
* POSTing the empty object to the challenge URL. The payload
* must encode as the JSON object {}, so a stdClass is used: an
* empty PHP array would encode as the JSON array [], which the
* certificate authority rejects as a malformed request.
*
* @param array $challenge the challenge object
* @return array the http() result
*/
public function notifyChallengeReady($challenge)
{
return $this->post($challenge["url"], new \stdClass());
}
/**
* Polls a URL (order or authorization) until its status leaves
* the pending/processing state or the attempt budget runs out.
*
* @param string $url the resource URL to poll
* @param int $attempts maximum poll attempts
* @param int $delay seconds to wait between attempts
* @return array|null the final decoded resource, or null
*/
public function pollStatus($url, $attempts = 20, $delay = 3)
{
for ($i = 0; $i < $attempts; $i++) {
$response = $this->post($url, null);
$resource = json_decode($response["body"], true);
$status = $resource["status"] ?? "";
if ($status !== "pending" && $status !== "processing") {
return $resource;
}
sleep($delay);
}
return null;
}
/**
* Finalizes an order by submitting the DER-encoded CSR, which
* must cover exactly the order's identifiers.
*
* @param array $order the order (must have a 'finalize' URL)
* @param string $csr_der the CSR in DER form
* @return array decoded order after finalize
*/
public function finalizeOrder($order, $csr_der)
{
$response = $this->post($order["finalize"],
["csr" => self::base64url($csr_der)]);
return json_decode($response["body"], true);
}
/**
* Downloads the issued certificate chain (PEM) once an order is
* valid and carries a certificate URL.
*
* @param array $order the valid order with a 'certificate' URL
* @return string|null PEM certificate chain, or null
*/
public function downloadCertificate($order)
{
if (empty($order["certificate"])) {
return null;
}
$response = $this->post($order["certificate"], null);
return $response["body"];
}
/**
* Queries the ACME Renewal Information endpoint for a
* certificate and returns the suggested renewal window plus any
* Retry-After the server asked for, so a renewal job can renew
* when the certificate authority recommends rather than on a
* fixed age. Returns null if the server has no ARI endpoint or
* gives no window.
*
* @param string $ari_cert_id the ARI certificate identifier
* (base64url of the authority key id and serial, per the
* ARI specification)
* @return array|null ['start' => int, 'end' => int,
* 'retry_after' => int] as unix timestamps/seconds, or null
*/
public function renewalInfo($ari_cert_id)
{
$base = $this->directory[self::RENEWAL_INFO] ?? null;
if ($base === null) {
return null;
}
$response = $this->http($base . "/" . $ari_cert_id);
$info = json_decode($response["body"], true);
if (!isset($info["suggestedWindow"])) {
return null;
}
$retry_after = 0;
if (isset($response["headers"]["retry-after"])) {
$retry_after =
(int)$response["headers"]["retry-after"];
}
return [
"start" => strtotime($info["suggestedWindow"]["start"]),
"end" => strtotime($info["suggestedWindow"]["end"]),
"retry_after" => $retry_after,
];
}
}