/ src / library / AcmeClient.php
<?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,
        ];
    }
}
X