/ tests / CsrBuilderTest.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\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");
    }
}
X