<?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\tests;
use seekquarry\yioop\library\CsrBuilder;
use seekquarry\yioop\library\UnitTest;
/**
* Tests for CsrBuilder: the multi-SAN CSR generator build(), the
* base64url encoder, the pemToDer armor stripper, the AKI key-id
* parser akiKeyIdBytes(), and the RFC 9773 ARI certificate id
* computation ariCertId(). The CSR cases generate a real key and
* request and parse them back; the ARI cases issue a leaf from a
* throwaway CA so the certificate carries an Authority Key
* Identifier and a multi-byte serial.
*
* @author Chris Pollett
*/
class CsrBuilderTest extends UnitTest
{
/**
* No setup state needed; CsrBuilder's methods are static and
* each case builds its own key material.
*/
public function setUp()
{
}
/**
* No teardown either; setUp does nothing.
*/
public function tearDown()
{
}
/**
* Re-armors a DER CSR as PEM so it can be parsed back by the
* OpenSSL command line in the SAN and key-match checks.
*
* @param string $der DER bytes of a certificate request
* @return string PEM text
*/
private function csrDerToPem($der)
{
return "-----BEGIN CERTIFICATE REQUEST-----\n" .
chunk_split(base64_encode($der), 64, "\n") .
"-----END CERTIFICATE REQUEST-----\n";
}
/**
* Issues a leaf certificate signed by a throwaway CA with the
* given serial, so the leaf has an Authority Key Identifier and
* a real serial number for the ARI tests.
*
* @param int $serial serial number to assign the leaf
* @return string the leaf certificate in PEM form
*/
private function issueLeaf($serial)
{
$ca_key = openssl_pkey_new(["private_key_bits" => 2048]);
$ca = openssl_csr_sign(openssl_csr_new(
["commonName" => "Test CA"], $ca_key,
["digest_alg" => "sha256"]), null, $ca_key, 3650,
["digest_alg" => "sha256"]);
$leaf_key = openssl_pkey_new(["private_key_bits" => 2048]);
$leaf = openssl_csr_sign(openssl_csr_new(
["commonName" => "example.test"], $leaf_key,
["digest_alg" => "sha256"]), $ca, $ca_key, 90,
["digest_alg" => "sha256"], $serial);
$leaf_pem = "";
openssl_x509_export($leaf, $leaf_pem);
return $leaf_pem;
}
/**
* build() returns both a private key PEM and a DER CSR for a
* multi-domain list.
*/
public function buildReturnsKeyAndCsrTestCase()
{
$result = CsrBuilder::build(
["pollett.org", "www.pollett.org"], 2048);
$this->assertTrue(is_array($result),
"build returns an array for a valid domain list");
$this->assertTrue(
strpos($result["key_pem"], "PRIVATE KEY") !== false,
"result carries a private key PEM");
$this->assertTrue(strlen($result["csr_der"]) > 0,
"result carries non-empty CSR DER bytes");
}
/**
* Every requested domain, including the principal, appears in
* the CSR's subjectAltName.
*/
public function buildCsrContainsAllSansTestCase()
{
$domains = ["pollett.org", "www.pollett.org",
"mta-sts.pollett.org"];
$result = CsrBuilder::build($domains, 2048);
$pem = $this->csrDerToPem($result["csr_der"]);
$tmp = tempnam(sys_get_temp_dir(), "csr");
file_put_contents($tmp, $pem);
$text = shell_exec("openssl req -in " . escapeshellarg($tmp) .
" -noout -text 2>&1");
@unlink($tmp);
foreach ($domains as $domain) {
$this->assertTrue(
strpos($text, "DNS:" . $domain) !== false,
"SAN list contains " . $domain);
}
}
/**
* The first domain is the certificate request's common name.
*/
public function buildCnIsPrincipalTestCase()
{
$result = CsrBuilder::build(
["pollett.org", "www.pollett.org"], 2048);
$pem = $this->csrDerToPem($result["csr_der"]);
$tmp = tempnam(sys_get_temp_dir(), "csr");
file_put_contents($tmp, $pem);
$text = shell_exec("openssl req -in " . escapeshellarg($tmp) .
" -noout -subject 2>&1");
@unlink($tmp);
$this->assertTrue(strpos($text, "pollett.org") !== false,
"subject names the principal domain");
}
/**
* The generated private key matches the generated CSR (their
* RSA moduli are equal).
*/
public function buildKeyMatchesCsrTestCase()
{
$result = CsrBuilder::build(["pollett.org"], 2048);
$pem = $this->csrDerToPem($result["csr_der"]);
$key_file = tempnam(sys_get_temp_dir(), "key");
$csr_file = tempnam(sys_get_temp_dir(), "csr");
file_put_contents($key_file, $result["key_pem"]);
file_put_contents($csr_file, $pem);
$key_mod = trim(shell_exec("openssl rsa -in " .
escapeshellarg($key_file) . " -noout -modulus 2>&1"));
$csr_mod = trim(shell_exec("openssl req -in " .
escapeshellarg($csr_file) . " -noout -modulus 2>&1"));
@unlink($key_file);
@unlink($csr_file);
$this->assertTrue($key_mod !== "" && $key_mod === $csr_mod,
"private key modulus matches CSR modulus");
}
/**
* A single-domain list is valid and produces a usable CSR.
*/
public function buildSingleDomainTestCase()
{
$result = CsrBuilder::build(["pollett.org"], 2048);
$this->assertTrue(is_array($result) &&
strlen($result["csr_der"]) > 0,
"single-domain build produces a CSR");
}
/**
* An empty domain list returns null rather than a CSR.
*/
public function buildEmptyDomainsReturnsNullTestCase()
{
$result = CsrBuilder::build([], 2048);
$this->assertTrue($result === null,
"empty domain list returns null");
}
/**
* base64url uses the URL-safe alphabet and drops padding.
*/
public function base64urlEncodingTestCase()
{
$this->assertEqual("Zm9v", CsrBuilder::base64url("foo"),
"foo encodes without padding");
$encoded = CsrBuilder::base64url("\xff\xef\xfe");
$this->assertTrue(strpbrk($encoded, "+/=") === false,
"no +, / or = characters in base64url output");
}
/**
* pemToDer strips the armor and base64-decodes, and returns
* null when the requested armor type is absent.
*/
public function pemToDerRoundTripTestCase()
{
$der = random_bytes(40);
$pem = "-----BEGIN CERTIFICATE REQUEST-----\n" .
chunk_split(base64_encode($der), 64, "\n") .
"-----END CERTIFICATE REQUEST-----\n";
$decoded = CsrBuilder::pemToDer($pem, "CERTIFICATE REQUEST");
$this->assertEqual(bin2hex($der), bin2hex($decoded),
"pemToDer recovers the original DER bytes");
$missing = CsrBuilder::pemToDer($pem, "PRIVATE KEY");
$this->assertTrue($missing === null,
"pemToDer returns null when the armor type is absent");
}
/**
* akiKeyIdBytes parses the colon-hex key id, and tolerates a
* leading keyid: label and trailing lines.
*/
public function akiKeyIdBytesParsingTestCase()
{
$plain = CsrBuilder::akiKeyIdBytes("2C:48:50:14");
$this->assertEqual("2c485014", bin2hex($plain),
"colon-hex key id parses to raw bytes");
$prefixed = CsrBuilder::akiKeyIdBytes(
"keyid:2C:48:50:14\nDirName:/CN=CA\n");
$this->assertEqual("2c485014", bin2hex($prefixed),
"keyid: prefix and trailing lines are ignored");
$empty = CsrBuilder::akiKeyIdBytes("");
$this->assertTrue($empty === null,
"empty AKI returns null");
}
/**
* ariCertId equals the base64url of the AKI key id, a dot, and
* the base64url of the serial, matching an independent
* computation from the same certificate.
*/
public function ariCertIdMatchesIndependentTestCase()
{
$leaf_pem = $this->issueLeaf(0x1122334455);
$id = CsrBuilder::ariCertId($leaf_pem);
$parsed = openssl_x509_parse($leaf_pem);
$aki_hex = preg_replace('/[^0-9A-Fa-f]/', "",
strtok($parsed["extensions"]["authorityKeyIdentifier"],
"\n"));
$aki_b = rtrim(strtr(base64_encode(hex2bin($aki_hex)),
"+/", "-_"), "=");
$serial_hex = $parsed["serialNumberHex"];
if (strlen($serial_hex) % 2 === 1) {
$serial_hex = "0" . $serial_hex;
}
$serial_b = rtrim(strtr(base64_encode(hex2bin($serial_hex)),
"+/", "-_"), "=");
$this->assertEqual($aki_b . "." . $serial_b, $id,
"ARI cert id matches independent AKI.serial computation");
}
/**
* The serial half of the ARI id decodes back to the issued
* certificate's serial number.
*/
public function ariCertIdSerialRoundTripTestCase()
{
$leaf_pem = $this->issueLeaf(0x1122334455);
$id = CsrBuilder::ariCertId($leaf_pem);
$parts = explode(".", $id);
$serial_b64 = strtr($parts[1], "-_", "+/");
$pad = strlen($serial_b64) % 4;
if ($pad > 0) {
$serial_b64 .= str_repeat("=", 4 - $pad);
}
$serial_bytes = base64_decode($serial_b64);
$this->assertEqual("1122334455", bin2hex($serial_bytes),
"serial half of the ARI id is the certificate serial");
}
/**
* selfSigned produces a self-signed certificate and matching
* key covering the requested domains as subject alternative
* names.
*/
public function selfSignedCoversDomainsTestCase()
{
$result = CsrBuilder::selfSigned(
["pollett.org", "www.pollett.org"], 30, 2048);
$this->assertTrue(is_array($result) &&
strpos($result["cert_pem"], "BEGIN CERTIFICATE") !== false
&& strpos($result["key_pem"], "PRIVATE KEY") !== false,
"selfSigned returns a cert and key");
$cert_file = tempnam(sys_get_temp_dir(), "ssc");
file_put_contents($cert_file, $result["cert_pem"]);
$text = shell_exec("openssl x509 -in " .
escapeshellarg($cert_file) . " -noout -text 2>&1");
@unlink($cert_file);
$this->assertTrue(
strpos($text, "DNS:pollett.org") !== false &&
strpos($text, "DNS:www.pollett.org") !== false,
"both domains appear as subject alternative names");
}
/**
* The selfSigned key matches its certificate (equal moduli).
*/
public function selfSignedKeyMatchesCertTestCase()
{
$result = CsrBuilder::selfSigned(["pollett.org"], 30, 2048);
$cert_file = tempnam(sys_get_temp_dir(), "ssc");
$key_file = tempnam(sys_get_temp_dir(), "ssk");
file_put_contents($cert_file, $result["cert_pem"]);
file_put_contents($key_file, $result["key_pem"]);
$cert_mod = trim(shell_exec("openssl x509 -in " .
escapeshellarg($cert_file) . " -noout -modulus 2>&1"));
$key_mod = trim(shell_exec("openssl rsa -in " .
escapeshellarg($key_file) . " -noout -modulus 2>&1"));
@unlink($cert_file);
@unlink($key_file);
$this->assertTrue($cert_mod !== "" && $cert_mod === $key_mod,
"self-signed key modulus matches its certificate");
}
/**
* An empty domain list falls back to a localhost certificate so
* a bare secure-mode start still has a certificate to bind with.
*/
public function selfSignedEmptyDomainsTestCase()
{
$result = CsrBuilder::selfSigned([], 30, 2048);
$cert_file = tempnam(sys_get_temp_dir(), "ssc");
file_put_contents($cert_file, $result["cert_pem"]);
$text = shell_exec("openssl x509 -in " .
escapeshellarg($cert_file) . " -noout -text 2>&1");
@unlink($cert_file);
$this->assertTrue(strpos($text, "DNS:localhost") !== false,
"empty domain list yields a localhost certificate");
}
}