<?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\configs as C;
use seekquarry\yioop\library\certificates\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
{
/**
* TEST_KEY_BITS is the RSA key size the cases generate keys at. No
* case checks the key size, and a key this size is generated many
* times faster than a 2048-bit one.
*/
const TEST_KEY_BITS = 1024;
/**
* No setup state needed; CsrBuilder's methods are static and
* each case builds its own key material.
*/
public function setUp()
{
CsrBuilder::$temp_folder = C\TEST_DIR . "/test_files";
/* openssl keeps its random seed in the file RANDFILE names, and
otherwise in .rnd in the home folder, so the seed is kept in
the tests folder. */
putenv("RANDFILE=" . C\TEST_DIR . "/test_files/rnd_" . getmypid());
}
/**
* No teardown either; setUp does nothing.
*/
public function tearDown()
{
CsrBuilder::$temp_folder = C\TEMP_DIR;
if (file_exists(C\TEST_DIR . "/test_files/rnd_" . getmypid())) {
unlink(C\TEST_DIR . "/test_files/rnd_" . getmypid());
}
putenv("RANDFILE");
}
/**
* 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" => self::TEST_KEY_BITS]);
$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" => self::TEST_KEY_BITS]);
$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"], self::TEST_KEY_BITS);
$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");
}
/**
* buildCsrContainsAllSansTestCase checks that every requested domain,
* the principal one included, is in the certificate request's
* subjectAltName, the extension that lists the names a certificate
* covers. Each name is stored there as plain text after the
* extension's identifier, 2.5.29.17, so the case looks for each
* domain in the request's bytes after that identifier.
*/
public function buildCsrContainsAllSansTestCase()
{
$domains = ["pollett.org", "www.pollett.org",
"mta-sts.pollett.org"];
$result = CsrBuilder::build($domains, self::TEST_KEY_BITS);
$after_names = strstr($result["csr_der"], self::SUBJECT_ALT_NAME);
foreach ($domains as $domain) {
$this->assertTrue($after_names !== false &&
str_contains($after_names, $domain),
"SAN list contains " . $domain);
}
}
/**
* SUBJECT_ALT_NAME is how the identifier of the subjectAltName
* extension, 2.5.29.17, is written in a certificate request's bytes.
*/
const SUBJECT_ALT_NAME = "\x06\x03\x55\x1d\x11";
/**
* buildCnIsPrincipalTestCase checks that the first domain is the
* certificate request's common name.
*/
public function buildCnIsPrincipalTestCase()
{
$result = CsrBuilder::build(
["pollett.org", "www.pollett.org"], self::TEST_KEY_BITS);
$subject = openssl_csr_get_subject(
$this->csrDerToPem($result["csr_der"]));
$this->assertEqual($subject["CN"] ?? "", "pollett.org",
"subject names the principal domain");
}
/**
* buildKeyMatchesCsrTestCase checks that the private key CsrBuilder
* makes belongs to the certificate request it makes: the two give the
* same RSA modulus.
*/
public function buildKeyMatchesCsrTestCase()
{
$result = CsrBuilder::build(["pollett.org"], self::TEST_KEY_BITS);
$csr_key = openssl_csr_get_public_key(
$this->csrDerToPem($result["csr_der"]));
$private_key = openssl_pkey_get_private($result["key_pem"]);
$csr_modulus = ($csr_key) ?
openssl_pkey_get_details($csr_key)["rsa"]["n"] ?? "" : "";
$key_modulus = ($private_key) ?
openssl_pkey_get_details($private_key)["rsa"]["n"] ?? "" : "";
$this->assertTrue($key_modulus !== "" &&
$key_modulus === $csr_modulus,
"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"], self::TEST_KEY_BITS);
$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([], self::TEST_KEY_BITS);
$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");
}
}