SeekquarrySeekquarry - Yioop Repo - Seekquarry

/ 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\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");
    }
}