<?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;
/**
* Builds the certificate signing request the ACME finalize step
* submits, and computes the ACME Renewal Information (ARI)
* certificate identifier the renewal job uses to ask the
* certificate authority when to renew.
*
* The CSR covers one principal domain and any number of additional
* subject alternative names (for example www and mta-sts) so a
* single issued certificate serves the whole list. The CSR is
* returned in DER form because ACME finalize wants the base64url of
* the DER, not the PEM.
*
* The ARI identifier (RFC 9773) is the base64url of the
* certificate's Authority Key Identifier key id, a dot, and the
* base64url of the certificate serial number, both as raw bytes.
* It is derived from an already-issued certificate, so the renewal
* job can compute it from the certificate on disk.
*
* @author Chris Pollett chris@pollett.org
*/
class CsrBuilder
{
/**
* URL-safe base64 without padding, matching the encoding ACME
* and ARI use.
*
* @param string $data raw bytes
* @return string base64url text
*/
public static function base64url($data)
{
return rtrim(strtr(base64_encode($data), "+/", "-_"), "=");
}
/**
* Generates a private key and a CSR covering the given domains,
* and returns both: the key as PEM (to be stored and installed
* alongside the issued certificate) and the CSR as DER (for the
* ACME finalize step). The first domain is the common name and
* every domain, including the first, is a subject alternative
* name, since modern clients ignore the common name and read
* the SAN list.
*
* @param array $domains DNS names; the first is the principal
* domain
* @param int $key_bits RSA key size for the certificate key
* @return array|null ['key_pem' => string, 'csr_der' => string]
* on success, or null on failure or an empty domain list
*/
public static function build($domains, $key_bits = 2048)
{
if (empty($domains)) {
return null;
}
$domains = array_values($domains);
$san_entries = [];
foreach ($domains as $domain) {
$san_entries[] = "DNS:" . $domain;
}
$san = implode(",", $san_entries);
/*
openssl_csr_new reads the subjectAltName from a config
section, so write a temporary OpenSSL config that adds
the SAN extension and point the request at it. The
request extensions section carries the SAN so finalize
receives every name on one certificate.
*/
$config_text = "[req]\n" .
"distinguished_name = dn\n" .
"req_extensions = req_ext\n" .
"[dn]\n" .
"[req_ext]\n" .
"subjectAltName = " . $san . "\n";
$config_file = tempnam(sys_get_temp_dir(), "csr");
if ($config_file === false) {
return null;
}
file_put_contents($config_file, $config_text);
$config = [
"digest_alg" => "sha256",
"config" => $config_file,
"req_extensions" => "req_ext",
];
$key = openssl_pkey_new([
"private_key_bits" => $key_bits,
"private_key_type" => OPENSSL_KEYTYPE_RSA,
"config" => $config_file,
]);
if ($key === false) {
@unlink($config_file);
return null;
}
$dn = ["commonName" => $domains[0]];
$csr = openssl_csr_new($dn, $key, $config);
if ($csr === false) {
@unlink($config_file);
return null;
}
$csr_pem = "";
openssl_csr_export($csr, $csr_pem);
$key_pem = "";
openssl_pkey_export($key, $key_pem, null,
["config" => $config_file]);
@unlink($config_file);
$csr_der = self::pemToDer($csr_pem,
"CERTIFICATE REQUEST");
if ($csr_der === null) {
return null;
}
return ["key_pem" => $key_pem, "csr_der" => $csr_der];
}
/**
* Generates a self-signed certificate and key covering the given
* domains, for bootstrapping the secure server when no managed
* certificate exists yet. This lets port 443 bind so the server
* runs (browsers warn on the untrusted certificate) and, when
* ACME is on, so the HTTP-01 challenge flow on port 80 can run
* to replace it with a real certificate. The first domain is the
* common name and every domain is a subject alternative name, so
* the placeholder at least names the right hosts.
*
* @param array $domains DNS names to cover; the first is the
* principal domain. When empty a single localhost name is
* used so a bare secure-mode start still binds.
* @param int $days validity period in days
* @param int $key_bits RSA key size
* @return array|null ['cert_pem' => string, 'key_pem' => string]
* on success, or null on failure
*/
public static function selfSigned($domains, $days = 365,
$key_bits = 2048)
{
$domains = empty($domains) ? ["localhost"] :
array_values($domains);
$san_entries = [];
foreach ($domains as $domain) {
$san_entries[] = "DNS:" . $domain;
}
$config_text = "[req]\n" .
"distinguished_name = dn\n" .
"x509_extensions = v3_ext\n" .
"[dn]\n" .
"[v3_ext]\n" .
"subjectAltName = " . implode(",", $san_entries) . "\n";
$config_file = tempnam(sys_get_temp_dir(), "ssc");
if ($config_file === false) {
return null;
}
file_put_contents($config_file, $config_text);
$config = [
"digest_alg" => "sha256",
"config" => $config_file,
"x509_extensions" => "v3_ext",
];
$key = openssl_pkey_new([
"private_key_bits" => $key_bits,
"private_key_type" => OPENSSL_KEYTYPE_RSA,
"config" => $config_file,
]);
if ($key === false) {
@unlink($config_file);
return null;
}
$dn = ["commonName" => $domains[0]];
$csr = openssl_csr_new($dn, $key, $config);
if ($csr === false) {
@unlink($config_file);
return null;
}
$cert = openssl_csr_sign($csr, null, $key, $days, $config);
if ($cert === false) {
@unlink($config_file);
return null;
}
$cert_pem = "";
openssl_x509_export($cert, $cert_pem);
$key_pem = "";
openssl_pkey_export($key, $key_pem, null,
["config" => $config_file]);
@unlink($config_file);
return ["cert_pem" => $cert_pem, "key_pem" => $key_pem];
}
/**
* Strips the PEM armor of the given type and base64-decodes the
* body to DER bytes.
*
* @param string $pem PEM text
* @param string $type the label inside the BEGIN/END lines, for
* example "CERTIFICATE REQUEST"
* @return string|null DER bytes, or null if the armor is absent
*/
public static function pemToDer($pem, $type)
{
$begin = "-----BEGIN " . $type . "-----";
$end = "-----END " . $type . "-----";
$start = strpos($pem, $begin);
$stop = strpos($pem, $end);
if ($start === false || $stop === false) {
return null;
}
$start += strlen($begin);
$body = substr($pem, $start, $stop - $start);
$body = preg_replace('/\s+/', "", $body);
$der = base64_decode($body, true);
return ($der === false) ? null : $der;
}
/**
* Computes the ARI certificate identifier (RFC 9773) for an
* issued certificate: the base64url of the Authority Key
* Identifier key id, a dot, then the base64url of the serial
* number, both as raw bytes. Returns null when the certificate
* lacks an Authority Key Identifier (which a properly issued
* certificate always has).
*
* @param string $cert_pem the issued certificate in PEM form
* (the leaf; if a chain is passed only the first
* certificate is parsed)
* @return string|null the ARI cert id, or null if it cannot be
* computed
*/
public static function ariCertId($cert_pem)
{
$parsed = openssl_x509_parse($cert_pem);
if ($parsed === false) {
return null;
}
$aki_raw = $parsed["extensions"]["authorityKeyIdentifier"]
?? "";
$aki_bytes = self::akiKeyIdBytes($aki_raw);
if ($aki_bytes === null) {
return null;
}
$serial_hex = $parsed["serialNumberHex"] ?? "";
if ($serial_hex === "") {
return null;
}
if (strlen($serial_hex) % 2 === 1) {
$serial_hex = "0" . $serial_hex;
}
$serial_bytes = hex2bin($serial_hex);
if ($serial_bytes === false) {
return null;
}
return self::base64url($aki_bytes) . "." .
self::base64url($serial_bytes);
}
/**
* Extracts the raw Authority Key Identifier key id bytes from
* the string openssl_x509_parse returns. That string is the key
* id as colon-separated hex, sometimes with a leading "keyid:"
* label and a trailing newline, so strip the label and
* separators and hex-decode what remains.
*
* @param string $aki_raw the parsed authorityKeyIdentifier
* extension string
* @return string|null raw key id bytes, or null if none present
*/
public static function akiKeyIdBytes($aki_raw)
{
$text = trim($aki_raw);
if ($text === "") {
return null;
}
if (stripos($text, "keyid:") !== false) {
$pos = stripos($text, "keyid:") + strlen("keyid:");
$text = substr($text, $pos);
}
/*
Keep only hex digits and colons from the first line, then
drop the colons. Any trailing lines (DirName, serial of
the issuer) are not part of the key id and are excluded
by stopping at the first newline.
*/
$newline = strpos($text, "\n");
if ($newline !== false) {
$text = substr($text, 0, $newline);
}
$hex = preg_replace('/[^0-9A-Fa-f]/', "", $text);
if ($hex === "" || strlen($hex) % 2 === 1) {
return null;
}
$bytes = hex2bin($hex);
return ($bytes === false) ? null : $bytes;
}
}