mirror of
https://github.com/LibreSign/libresign.git
synced 2025-12-18 05:20:45 +01:00
- Add reset() method to AppConfigOverwrite that clears overWrite and deleted arrays and returns self - Integrate reset() directly into getMockAppConfig() to ensure clean state on every call - All tests now automatically get clean AppConfig state without explicit reset calls - Prevents state pollution across test suites by resetting at the source - Simplifies test code by removing need for separate reset wrapper method Signed-off-by: Vitor Mattos <1079143+vitormattos@users.noreply.github.com>
876 lines
29 KiB
PHP
876 lines
29 KiB
PHP
<?php
|
|
|
|
declare(strict_types=1);
|
|
/**
|
|
* SPDX-FileCopyrightText: 2020-2024 LibreCode coop and contributors
|
|
* SPDX-License-Identifier: AGPL-3.0-or-later
|
|
*/
|
|
|
|
use OCA\Libresign\Db\CrlMapper;
|
|
use OCA\Libresign\Exception\EmptyCertificateException;
|
|
use OCA\Libresign\Exception\InvalidPasswordException;
|
|
use OCA\Libresign\Exception\LibresignException;
|
|
use OCA\Libresign\Handler\CertificateEngine\OpenSslHandler;
|
|
use OCA\Libresign\Service\CaIdentifierService;
|
|
use OCA\Libresign\Service\CertificatePolicyService;
|
|
use OCA\Libresign\Service\SerialNumberService;
|
|
use OCP\Files\AppData\IAppDataFactory;
|
|
use OCP\IAppConfig;
|
|
use OCP\IConfig;
|
|
use OCP\IDateTimeFormatter;
|
|
use OCP\ITempManager;
|
|
use OCP\IURLGenerator;
|
|
use PHPUnit\Framework\Attributes\DataProvider;
|
|
use Psr\Log\LoggerInterface;
|
|
|
|
final class OpenSslHandlerTest extends \OCA\Libresign\Tests\Unit\TestCase {
|
|
private IConfig $config;
|
|
private IAppConfig $appConfig;
|
|
private IAppDataFactory $appDataFactory;
|
|
private IDateTimeFormatter $dateTimeFormatter;
|
|
private ITempManager $tempManager;
|
|
private OpenSslHandler $openSslHandler;
|
|
protected CertificatePolicyService $certificatePolicyService;
|
|
private SerialNumberService $serialNumberService;
|
|
private IURLGenerator $urlGenerator;
|
|
private CaIdentifierService $caIdentifierService;
|
|
private CrlMapper $crlMapper;
|
|
private LoggerInterface $logger;
|
|
private string $tempDir;
|
|
public function setUp(): void {
|
|
$this->config = \OCP\Server::get(IConfig::class);
|
|
$this->appConfig = $this->getMockAppConfigWithReset();
|
|
$this->appDataFactory = \OCP\Server::get(IAppDataFactory::class);
|
|
$this->dateTimeFormatter = \OCP\Server::get(IDateTimeFormatter::class);
|
|
$this->tempManager = \OCP\Server::get(ITempManager::class);
|
|
$this->certificatePolicyService = \OCP\Server::get(CertificatePolicyService::class);
|
|
$this->serialNumberService = \OCP\Server::get(SerialNumberService::class);
|
|
$this->urlGenerator = \OCP\Server::get(IURLGenerator::class);
|
|
$this->tempDir = $this->tempManager->getTemporaryFolder('certificate');
|
|
$this->caIdentifierService = \OCP\Server::get(CaIdentifierService::class);
|
|
$this->crlMapper = \OCP\Server::get(CrlMapper::class);
|
|
$this->logger = \OCP\Server::get(LoggerInterface::class);
|
|
$this->caIdentifierService->generateCaId('openssl');
|
|
}
|
|
|
|
private function getInstance() {
|
|
return new OpenSslHandler(
|
|
$this->config,
|
|
$this->appConfig,
|
|
$this->appDataFactory,
|
|
$this->dateTimeFormatter,
|
|
$this->tempManager,
|
|
$this->certificatePolicyService,
|
|
$this->urlGenerator,
|
|
$this->serialNumberService,
|
|
$this->caIdentifierService,
|
|
$this->logger,
|
|
$this->crlMapper,
|
|
);
|
|
}
|
|
|
|
public function testEmptyCertificate(): void {
|
|
$signerInstance = $this->getInstance();
|
|
|
|
// Test invalid password
|
|
$this->expectException(EmptyCertificateException::class);
|
|
$signerInstance->readCertificate('', '');
|
|
}
|
|
|
|
public function testEmptyCommonNameThrowsException(): void {
|
|
$rootInstance = $this->getInstance();
|
|
$this->expectException(EmptyCertificateException::class);
|
|
$this->expectExceptionMessage('Common Name (CN) cannot be empty for root certificate');
|
|
$rootInstance->generateRootCert('', []);
|
|
}
|
|
|
|
public function testInvalidPassword(): void {
|
|
// Create root cert
|
|
$rootInstance = $this->getInstance();
|
|
$rootInstance->generateRootCert('Test Root CA', []);
|
|
|
|
// Create signer cert
|
|
$signerInstance = $this->getInstance();
|
|
$signerInstance->setCommonName('Test User');
|
|
$signerInstance->setHosts(['user@email.tld']);
|
|
$signerInstance->setPassword('right password');
|
|
$certificateContent = $signerInstance->generateCertificate();
|
|
|
|
// Test invalid password
|
|
$this->expectException(InvalidPasswordException::class);
|
|
$signerInstance->readCertificate($certificateContent, 'invalid password');
|
|
}
|
|
|
|
public function testMaxLengthOfDistinguishedNamesWithSuccess(): void {
|
|
// Create root cert
|
|
$rootInstance = $this->getInstance();
|
|
$rootInstance->generateRootCert('Test Root CA', []);
|
|
|
|
// Create signer cert
|
|
$signerInstance = $this->getInstance();
|
|
$longName = str_repeat('a', 64);
|
|
$signerInstance->setCommonName($longName);
|
|
$signerInstance->setPassword('123456');
|
|
$certificateContent = $signerInstance->generateCertificate();
|
|
$parsed = $signerInstance->readCertificate($certificateContent, '123456');
|
|
$this->assertEquals($longName, $parsed['subject']['CN']);
|
|
}
|
|
|
|
public function testBiggerThanMaxLengthOfDistinguishedNamesWithError(): void {
|
|
// Create root cert
|
|
$rootInstance = $this->getInstance();
|
|
$rootInstance->generateRootCert('Test Root CA', []);
|
|
|
|
// Create signer cert
|
|
$signerInstance = $this->getInstance();
|
|
$longName = str_repeat('a', 65);
|
|
$signerInstance->setCommonName($longName);
|
|
$signerInstance->setPassword('123456');
|
|
$this->expectException(LibresignException::class);
|
|
$signerInstance->generateCertificate();
|
|
}
|
|
|
|
/**
|
|
* @dataProvider dataReadCertificate
|
|
*/
|
|
public function testReadCertificate(
|
|
string $commonName,
|
|
string $signerName,
|
|
array $hosts,
|
|
string $password,
|
|
array $csrNames,
|
|
array $root,
|
|
): void {
|
|
// Create root cert
|
|
$rootInstance = $this->getInstance();
|
|
if (isset($root['CN'])) {
|
|
$rootInstance->setCommonName($root['CN']);
|
|
}
|
|
if (isset($root['C'])) {
|
|
$rootInstance->setCountry($root['C']);
|
|
}
|
|
if (isset($root['ST'])) {
|
|
$rootInstance->setState($root['ST']);
|
|
}
|
|
if (isset($root['O'])) {
|
|
$rootInstance->setOrganization($root['O']);
|
|
}
|
|
if (isset($root['OU'])) {
|
|
$rootInstance->setOrganizationalUnit([$root['OU']]);
|
|
}
|
|
$rootInstance->generateRootCert($commonName, $root);
|
|
|
|
// Create signer cert
|
|
$signerInstance = $this->getInstance();
|
|
$signerInstance->setHosts($hosts);
|
|
$signerInstance->setPassword($password);
|
|
$signerInstance->setFriendlyName($signerName);
|
|
if (isset($csrNames['CN'])) {
|
|
$signerInstance->setCommonName($csrNames['CN']);
|
|
} else {
|
|
$signerInstance->setCommonName($signerName);
|
|
$csrNames['CN'] = $signerName; // Add to expected values for comparison
|
|
}
|
|
if (isset($csrNames['C'])) {
|
|
$signerInstance->setCountry($csrNames['C']);
|
|
}
|
|
if (isset($csrNames['ST'])) {
|
|
$signerInstance->setState($csrNames['ST']);
|
|
}
|
|
if (isset($csrNames['O'])) {
|
|
$signerInstance->setOrganization($csrNames['O']);
|
|
}
|
|
if (isset($csrNames['OU'])) {
|
|
$signerInstance->setOrganizationalUnit([$csrNames['OU']]);
|
|
}
|
|
$certificateContent = $signerInstance->generateCertificate();
|
|
|
|
// Parse signer cert
|
|
$parsed = $signerInstance->readCertificate($certificateContent, $password);
|
|
|
|
// Test total elements of extracerts
|
|
// The correct content is: cert signer, intermediate certs (if have), root cert
|
|
$this->assertArrayHasKey('extracerts', $parsed);
|
|
$this->assertCount(2, $parsed['extracerts']);
|
|
|
|
// Test name
|
|
$name = $this->csrArrayToString($csrNames);
|
|
$this->assertEquals($parsed['name'], $name);
|
|
$this->assertJsonStringEqualsJsonString(
|
|
json_encode($csrNames),
|
|
json_encode($parsed['subject'])
|
|
);
|
|
|
|
// Test subject
|
|
$this->assertEquals($csrNames, $parsed['subject']);
|
|
|
|
// Test issuer ony if was defined root distinguished names
|
|
if (count($root) === count($parsed['issuer'])) {
|
|
$this->assertEquals($root, $parsed['issuer']);
|
|
}
|
|
}
|
|
|
|
private function csrArrayToString(array $csr): string {
|
|
$return = '';
|
|
foreach ($csr as $key => $value) {
|
|
$return .= "/$key=$value";
|
|
}
|
|
return $return;
|
|
}
|
|
|
|
private function parseDnString(string $dn): array {
|
|
$result = [];
|
|
// Parse DN string like "/C=US/ST=State/O=Org/CN=Name"
|
|
$parts = explode('/', trim($dn, '/'));
|
|
foreach ($parts as $part) {
|
|
if (strpos($part, '=') !== false) {
|
|
[$key, $value] = explode('=', $part, 2);
|
|
$result[$key] = $value;
|
|
}
|
|
}
|
|
return $result;
|
|
}
|
|
|
|
public static function dataReadCertificate(): array {
|
|
return [
|
|
[
|
|
'common name',
|
|
'Signer Name',
|
|
['user@domain.tld'],
|
|
'password',
|
|
[
|
|
'C' => 'CT',
|
|
'ST' => 'Some-State',
|
|
'O' => 'Organization Name',
|
|
],
|
|
[],
|
|
],
|
|
[
|
|
'common name',
|
|
'Signer Name',
|
|
['account:test'],
|
|
'password',
|
|
[
|
|
'C' => 'CT',
|
|
'ST' => 'Some-State',
|
|
'O' => 'Organization Name',
|
|
'OU' => 'Organization Unit',
|
|
'CN' => 'Common Name',
|
|
],
|
|
[
|
|
'C' => 'RT',
|
|
'ST' => 'Root-State',
|
|
'O' => 'Root Organization Name',
|
|
'OU' => 'Root Organization Unit',
|
|
'CN' => 'Root Common Name',
|
|
'UID' => 'account:test'
|
|
],
|
|
],
|
|
[
|
|
'common name',
|
|
'Signer Name',
|
|
['user@domain.tld'],
|
|
'password',
|
|
[
|
|
'C' => 'CT',
|
|
'ST' => 'Some-State',
|
|
'O' => 'Organization Name',
|
|
'OU' => 'Organization Unit',
|
|
'CN' => 'Common Name',
|
|
],
|
|
[
|
|
'C' => 'RT',
|
|
'ST' => 'Root-State',
|
|
'O' => 'Root Organization Name',
|
|
'OU' => 'Root Organization Unit',
|
|
'CN' => 'Root Common Name',
|
|
'UID' => 'email:user@domain.tld'
|
|
],
|
|
],
|
|
];
|
|
}
|
|
|
|
public function testSerialNumberGeneration(): void {
|
|
$rootInstance = $this->getInstance();
|
|
$rootInstance->generateRootCert('Test Root CA', []);
|
|
|
|
$signerInstance = $this->getInstance();
|
|
$signerInstance->setCommonName('Test User');
|
|
$signerInstance->setPassword('123456');
|
|
|
|
$certificate = $signerInstance->generateCertificate();
|
|
$parsed = $signerInstance->readCertificate($certificate, '123456');
|
|
|
|
$this->assertArrayHasKey('serialNumber', $parsed, 'Certificate should have serialNumber field');
|
|
$this->assertArrayHasKey('serialNumberHex', $parsed, 'Certificate should have serialNumberHex field');
|
|
$this->assertNotNull($parsed['serialNumber'], 'Serial number should not be null');
|
|
$this->assertNotNull($parsed['serialNumberHex'], 'Serial number hex should not be null');
|
|
|
|
$this->assertNotEquals('0', $parsed['serialNumber'], 'Serial number should not be zero');
|
|
$this->assertNotEquals('00', $parsed['serialNumberHex'], 'Serial number hex should not be zero');
|
|
|
|
$serialInt = (int)$parsed['serialNumber'];
|
|
$this->assertGreaterThanOrEqual(1000000, $serialInt, 'Serial number should be >= 1000000');
|
|
$this->assertLessThanOrEqual(PHP_INT_MAX, $serialInt, 'Serial number should be <= PHP_INT_MAX');
|
|
|
|
$this->assertIsNumeric($parsed['serialNumber'], 'Serial number should be numeric');
|
|
$this->assertMatchesRegularExpression('/^[0-9A-Fa-f]+$/', $parsed['serialNumberHex'], 'Serial number hex should contain only hex characters');
|
|
}
|
|
|
|
public function testUniqueSerialNumbers(): void {
|
|
$rootInstance = $this->getInstance();
|
|
$rootInstance->generateRootCert('Test Root CA', []);
|
|
|
|
$serialNumbers = [];
|
|
$numCertificates = 3;
|
|
|
|
for ($i = 0; $i < $numCertificates; $i++) {
|
|
$signerInstance = $this->getInstance();
|
|
$signerInstance->setCommonName("Test Certificate $i");
|
|
$signerInstance->setPassword('123456');
|
|
$certificateContent = $signerInstance->generateCertificate();
|
|
$parsed = $signerInstance->readCertificate($certificateContent, '123456');
|
|
|
|
$serialNumber = $parsed['serialNumber'];
|
|
|
|
$this->assertNotEquals('0', $serialNumber, "Certificate $i should not have serial number 0");
|
|
|
|
$this->assertNotContains($serialNumber, $serialNumbers, "Certificate $i should have unique serial number");
|
|
|
|
$serialNumbers[] = $serialNumber;
|
|
}
|
|
|
|
$this->assertCount($numCertificates, array_unique($serialNumbers), 'All serial numbers should be unique');
|
|
}
|
|
|
|
public function testRealCertificateRevocationInCrl(): void {
|
|
$this->caIdentifierService->generateCaId('openssl');
|
|
|
|
$rootInstance = $this->getInstance();
|
|
$rootInstance->generateRootCert('Test Root CA', []);
|
|
|
|
$signerInstance = $this->getInstance();
|
|
$signerInstance->setCommonName('Test User for Revocation');
|
|
$signerInstance->setPassword('123456');
|
|
$certificateContent = $signerInstance->generateCertificate();
|
|
|
|
$parsed = $signerInstance->readCertificate($certificateContent, '123456');
|
|
|
|
$revokedCert = new \OCA\Libresign\Db\Crl();
|
|
$revokedCert->setSerialNumber($parsed['serialNumberHex']);
|
|
$revokedCert->setRevokedAt(new \DateTime('2025-01-01 12:00:00'));
|
|
|
|
$configPath = $rootInstance->getCurrentConfigPath();
|
|
$pkiDirName = basename($configPath);
|
|
preg_match('/^([^_]+)_(\d+)_(.+)$/', $pkiDirName, $matches);
|
|
|
|
$crlDer = $rootInstance->generateCrlDer([$revokedCert], $matches[1], (int)$matches[2], 1);
|
|
|
|
$tempCrlFile = $this->tempManager->getTemporaryFile('.crl');
|
|
file_put_contents($tempCrlFile, $crlDer);
|
|
|
|
exec(sprintf('openssl crl -in %s -inform DER -text -noout', escapeshellarg($tempCrlFile)), $output);
|
|
|
|
$crlText = implode("\n", $output);
|
|
|
|
$this->assertMatchesRegularExpression('/Serial Number: 0*' . preg_quote($parsed['serialNumberHex'], '/') . '/', $crlText);
|
|
|
|
unlink($tempCrlFile);
|
|
}
|
|
|
|
public static function revokedCertificatesProvider(): array {
|
|
return [
|
|
'empty CRL (no revoked certificates)' => [
|
|
'certificates' => [],
|
|
],
|
|
'single revoked certificate' => [
|
|
'certificates' => [
|
|
['revokedAt' => '2025-01-01 12:00:00'],
|
|
],
|
|
],
|
|
'two revoked certificates' => [
|
|
'certificates' => [
|
|
['revokedAt' => '2025-01-01 12:00:00'],
|
|
['revokedAt' => '2025-01-02 15:30:00'],
|
|
],
|
|
],
|
|
'three revoked certificates' => [
|
|
'certificates' => [
|
|
['revokedAt' => '2025-01-01 12:00:00'],
|
|
['revokedAt' => '2025-01-02 15:30:00'],
|
|
['revokedAt' => '2025-01-03 18:45:00'],
|
|
],
|
|
],
|
|
'five revoked certificates' => [
|
|
'certificates' => [
|
|
['revokedAt' => '2025-01-01 12:00:00'],
|
|
['revokedAt' => '2025-01-02 15:30:00'],
|
|
['revokedAt' => '2025-01-03 18:45:00'],
|
|
['revokedAt' => '2025-01-04 09:15:00'],
|
|
['revokedAt' => '2025-01-05 14:20:00'],
|
|
],
|
|
],
|
|
];
|
|
}
|
|
|
|
/**
|
|
* @dataProvider revokedCertificatesProvider
|
|
*/
|
|
public function testGenerateCrlDerWithRevokedCertificates(array $certificates): void {
|
|
$this->caIdentifierService->generateCaId('openssl');
|
|
|
|
$rootInstance = $this->getInstance();
|
|
$rootInstance->generateRootCert('Test Root CA', []);
|
|
|
|
$revokedCertificates = [];
|
|
$serialNumbers = [];
|
|
|
|
foreach ($certificates as $certData) {
|
|
$serialNumber = bin2hex(random_bytes(10));
|
|
$serialNumbers[] = $serialNumber;
|
|
|
|
$revokedCert = new \OCA\Libresign\Db\Crl();
|
|
$revokedCert->setSerialNumber($serialNumber);
|
|
$revokedCert->setRevokedAt(new \DateTime($certData['revokedAt']));
|
|
$revokedCertificates[] = $revokedCert;
|
|
}
|
|
|
|
$configPath = $rootInstance->getCurrentConfigPath();
|
|
$this->assertDirectoryExists($configPath);
|
|
$this->assertFileExists($configPath . DIRECTORY_SEPARATOR . 'ca.pem');
|
|
$this->assertFileExists($configPath . DIRECTORY_SEPARATOR . 'ca-key.pem');
|
|
|
|
$pkiDirName = basename($configPath);
|
|
$this->assertMatchesRegularExpression('/^[^_]+_\d+_.+$/', $pkiDirName);
|
|
preg_match('/^([^_]+)_(\d+)_(.+)$/', $pkiDirName, $matches);
|
|
$instanceId = $matches[1];
|
|
$generation = (int)$matches[2];
|
|
$crlNumber = 42;
|
|
|
|
$crlDer = $rootInstance->generateCrlDer($revokedCertificates, $instanceId, $generation, $crlNumber);
|
|
|
|
$this->assertNotEmpty($crlDer);
|
|
$this->assertIsString($crlDer);
|
|
|
|
$tempCrlFile = $this->tempManager->getTemporaryFile('.crl');
|
|
try {
|
|
file_put_contents($tempCrlFile, $crlDer);
|
|
|
|
$crlTextCmd = sprintf(
|
|
'openssl crl -in %s -inform DER -text -noout',
|
|
escapeshellarg($tempCrlFile)
|
|
);
|
|
exec($crlTextCmd, $output, $exitCode);
|
|
|
|
$this->assertEquals(0, $exitCode, 'OpenSSL should successfully parse the CRL');
|
|
|
|
$crlText = implode("\n", $output);
|
|
|
|
$this->assertStringContainsString('Certificate Revocation List (CRL)', $crlText, 'Should be a valid CRL');
|
|
$this->assertStringContainsString('Issuer:', $crlText, 'CRL should contain Issuer');
|
|
$this->assertStringContainsString('Last Update:', $crlText, 'CRL should contain Last Update date');
|
|
$this->assertStringContainsString('Next Update:', $crlText, 'CRL should contain Next Update date');
|
|
$this->assertStringContainsString('Signature Algorithm:', $crlText, 'CRL should contain signature algorithm');
|
|
|
|
if (empty($certificates)) {
|
|
$this->assertStringContainsString('No Revoked Certificates', $crlText, 'Empty CRL should show "No Revoked Certificates"');
|
|
} else {
|
|
$this->assertStringNotContainsString('No Revoked Certificates', $crlText, 'CRL with revocations should not show "No Revoked Certificates"');
|
|
$this->assertStringContainsString('Revoked Certificates:', $crlText, 'CRL should have Revoked Certificates section');
|
|
|
|
$this->assertMatchesRegularExpression('/X509v3 CRL Number:\s+(\d+)/i', $crlText, 'CRL Number extension should be present');
|
|
preg_match('/X509v3 CRL Number:\s+(\d+)/i', $crlText, $crlMatches);
|
|
$actualCrlNumber = (int)$crlMatches[1];
|
|
$this->assertEquals($crlNumber, $actualCrlNumber, 'CRL Number should match the provided value');
|
|
|
|
foreach ($serialNumbers as $serialNumber) {
|
|
$normalizedSerial = ltrim(strtoupper($serialNumber), '0') ?: '0';
|
|
$this->assertStringContainsString($normalizedSerial, $crlText, "Serial number $serialNumber (normalized: $normalizedSerial) should appear in CRL");
|
|
}
|
|
}
|
|
|
|
$caCertPath = $configPath . DIRECTORY_SEPARATOR . 'ca.pem';
|
|
$verifyCmd = sprintf(
|
|
'openssl crl -in %s -inform DER -CAfile %s -noout 2>&1',
|
|
escapeshellarg($tempCrlFile),
|
|
escapeshellarg($caCertPath)
|
|
);
|
|
exec($verifyCmd, $verifyOutput, $verifyExitCode);
|
|
$verifyResult = implode("\n", $verifyOutput);
|
|
|
|
$this->assertEquals(0, $verifyExitCode, 'CRL signature verification should succeed. Output: ' . $verifyResult);
|
|
$this->assertStringContainsString('verify OK', $verifyResult, 'CRL signature should be valid');
|
|
|
|
} finally {
|
|
if (file_exists($tempCrlFile)) {
|
|
unlink($tempCrlFile);
|
|
}
|
|
}
|
|
}
|
|
|
|
public static function dataCrlSerialNumberNormalization(): array {
|
|
return [
|
|
'Serial with leading zeros (20 chars)' => [
|
|
'serialNumber' => '00e7a0b277a1008f5fe3',
|
|
'expectedInCrl' => 'E7A0B277A1008F5FE3'
|
|
],
|
|
'Serial without leading zeros (20 chars)' => [
|
|
'serialNumber' => 'e7a0b277a1008f5fe3',
|
|
'expectedInCrl' => 'E7A0B277A1008F5FE3'
|
|
],
|
|
'Serial with multiple leading zeros (8 chars)' => [
|
|
'serialNumber' => '00000001',
|
|
'expectedInCrl' => '01' // OpenSSL maintains at least 2 digits
|
|
],
|
|
'Serial number 1 (single char)' => [
|
|
'serialNumber' => '1',
|
|
'expectedInCrl' => '01' // OpenSSL maintains at least 2 digits
|
|
],
|
|
'Serial 0xFF with leading zeros' => [
|
|
'serialNumber' => '000000FF',
|
|
'expectedInCrl' => 'FF'
|
|
],
|
|
'Long serial with leading zeros (40 chars)' => [
|
|
'serialNumber' => '0023EE47B385E2D71D5B30A09AE34887D5AF595694',
|
|
'expectedInCrl' => '23EE47B385E2D71D5B30A09AE34887D5AF595694'
|
|
],
|
|
'Long serial without leading zeros (40 chars)' => [
|
|
'serialNumber' => '23EE47B385E2D71D5B30A09AE34887D5AF595694',
|
|
'expectedInCrl' => '23EE47B385E2D71D5B30A09AE34887D5AF595694'
|
|
],
|
|
'Very long serial with leading zeros (42 chars)' => [
|
|
'serialNumber' => '00A1B2C3D4E5F6789012345678901234567890ABCD',
|
|
'expectedInCrl' => 'A1B2C3D4E5F6789012345678901234567890ABCD'
|
|
],
|
|
'Serial starting with letter, no zeros' => [
|
|
'serialNumber' => 'FEDCBA9876543210',
|
|
'expectedInCrl' => 'FEDCBA9876543210'
|
|
],
|
|
'Serial starting with letter, with zeros' => [
|
|
'serialNumber' => '00FEDCBA9876543210',
|
|
'expectedInCrl' => 'FEDCBA9876543210'
|
|
],
|
|
'Lowercase long serial with zeros' => [
|
|
'serialNumber' => '00abcdef1234567890abcdef1234567890',
|
|
'expectedInCrl' => 'ABCDEF1234567890ABCDEF1234567890'
|
|
],
|
|
'Mixed case long serial' => [
|
|
'serialNumber' => '00AaBbCcDdEeFf1122334455',
|
|
'expectedInCrl' => 'AABBCCDDEEFF1122334455'
|
|
],
|
|
];
|
|
}
|
|
|
|
#[DataProvider('dataCrlSerialNumberNormalization')]
|
|
public function testCrlSerialNumberNormalization(string $serialNumber, string $expectedInCrl): void {
|
|
$this->caIdentifierService->generateCaId('openssl');
|
|
|
|
$rootInstance = $this->getInstance();
|
|
$rootInstance->generateRootCert('Test Root CA', []);
|
|
|
|
$revokedCert = new \OCA\Libresign\Db\Crl();
|
|
$revokedCert->setSerialNumber($serialNumber);
|
|
$revokedCert->setRevokedAt(new \DateTime('2025-01-01 12:00:00'));
|
|
|
|
$configPath = $rootInstance->getCurrentConfigPath();
|
|
$pkiDirName = basename($configPath);
|
|
preg_match('/^([^_]+)_(\d+)_(.+)$/', $pkiDirName, $matches);
|
|
$instanceId = $matches[1];
|
|
$generation = (int)$matches[2];
|
|
$crlNumber = 42;
|
|
|
|
$crlDer = $rootInstance->generateCrlDer([$revokedCert], $instanceId, $generation, $crlNumber);
|
|
|
|
$this->assertNotEmpty($crlDer, "CRL should be generated for serial: {$serialNumber}");
|
|
|
|
$tempCrlFile = $this->tempManager->getTemporaryFile('.crl');
|
|
try {
|
|
file_put_contents($tempCrlFile, $crlDer);
|
|
|
|
$crlTextCmd = sprintf(
|
|
'openssl crl -in %s -inform DER -text -noout',
|
|
escapeshellarg($tempCrlFile)
|
|
);
|
|
exec($crlTextCmd, $output, $exitCode);
|
|
|
|
$this->assertEquals(0, $exitCode, "OpenSSL should parse CRL for serial: {$serialNumber}");
|
|
|
|
$crlText = implode("\n", $output);
|
|
|
|
$this->assertStringContainsString(
|
|
$expectedInCrl,
|
|
$crlText,
|
|
"Expected serial '{$expectedInCrl}' should appear in CRL (input: '{$serialNumber}')"
|
|
);
|
|
|
|
$this->assertStringContainsString(
|
|
'Serial Number: ' . $expectedInCrl,
|
|
$crlText,
|
|
"Serial should appear with 'Serial Number:' prefix (input: '{$serialNumber}')"
|
|
);
|
|
|
|
} finally {
|
|
if (file_exists($tempCrlFile)) {
|
|
unlink($tempCrlFile);
|
|
}
|
|
}
|
|
}
|
|
|
|
public function testValidateRootCertificateNotFound(): void {
|
|
$handler = $this->getInstance();
|
|
|
|
$configPath = $handler->getCurrentConfigPath();
|
|
$rootCertPath = $configPath . DIRECTORY_SEPARATOR . 'ca.pem';
|
|
|
|
if (file_exists($rootCertPath)) {
|
|
unlink($rootCertPath);
|
|
}
|
|
|
|
$handler->validateRootCertificate();
|
|
|
|
$this->assertFileDoesNotExist($rootCertPath);
|
|
}
|
|
|
|
#[DataProvider('expiryScenarioProvider')]
|
|
public function testRootCertificateExpiryScenarios(
|
|
int $caExpiryDays,
|
|
int $leafExpiryDays,
|
|
int $ageInDays,
|
|
bool $needsRenewal,
|
|
string $description,
|
|
): void {
|
|
$this->appConfig->setValueInt('libresign', 'ca_expiry_in_days', $caExpiryDays);
|
|
$this->appConfig->setValueInt('libresign', 'expiry_in_days', $leafExpiryDays);
|
|
|
|
$handler = $this->getInstance();
|
|
|
|
$handler->generateRootCert('Test Root CA for ' . $description, []);
|
|
|
|
if ($ageInDays > 0) {
|
|
$this->simulateCertificateAging($handler, $ageInDays);
|
|
}
|
|
|
|
$handler->validateRootCertificate();
|
|
|
|
$configPath = $handler->getCurrentConfigPath();
|
|
$rootCertPath = $configPath . DIRECTORY_SEPARATOR . 'ca.pem';
|
|
$this->assertFileExists($rootCertPath);
|
|
|
|
$certContent = file_get_contents($rootCertPath);
|
|
$certInfo = openssl_x509_parse(openssl_x509_read($certContent));
|
|
|
|
$secondsPerDay = 60 * 60 * 24;
|
|
$remainingDays = (int)ceil(($certInfo['validTo_time_t'] - time()) / $secondsPerDay);
|
|
|
|
if ($needsRenewal) {
|
|
$this->assertLessThanOrEqual($leafExpiryDays, $remainingDays,
|
|
"Certificate should need renewal: remaining days ({$remainingDays}) <= leaf expiry days ({$leafExpiryDays})");
|
|
} else {
|
|
$this->assertGreaterThan($leafExpiryDays, $remainingDays,
|
|
"Certificate should NOT need renewal: remaining days ({$remainingDays}) > leaf expiry days ({$leafExpiryDays})");
|
|
}
|
|
}
|
|
|
|
public static function expiryScenarioProvider(): array {
|
|
return [
|
|
'newly_created' => [
|
|
'caExpiryDays' => 3650, // 10 years
|
|
'leafExpiryDays' => 365, // 1 year
|
|
'ageInDays' => 0, // No aging
|
|
'needsRenewal' => false,
|
|
'description' => 'Newly created certificate with 10 years validity',
|
|
],
|
|
'two_years_remaining' => [
|
|
'caExpiryDays' => 3650,
|
|
'leafExpiryDays' => 365,
|
|
'ageInDays' => 2920, // ~8 years passed, 2 years remaining
|
|
'needsRenewal' => false,
|
|
'description' => 'Certificate with 2 years remaining (no renewal needed)',
|
|
],
|
|
'at_renewal_threshold' => [
|
|
'caExpiryDays' => 3650,
|
|
'leafExpiryDays' => 365,
|
|
'ageInDays' => 3285, // Exactly 365 days remaining
|
|
'needsRenewal' => true,
|
|
'description' => 'Certificate at renewal threshold (365 days = leaf validity)',
|
|
],
|
|
'below_renewal_threshold' => [
|
|
'caExpiryDays' => 3650,
|
|
'leafExpiryDays' => 365,
|
|
'ageInDays' => 3380, // 270 days remaining
|
|
'needsRenewal' => true,
|
|
'description' => 'Certificate needing renewal (270 days < 365 days)',
|
|
],
|
|
'short_ca_valid' => [
|
|
'caExpiryDays' => 730, // 2 years
|
|
'leafExpiryDays' => 90, // 3 months
|
|
'ageInDays' => 0,
|
|
'needsRenewal' => false,
|
|
'description' => 'Short-lived CA (2 years) with short-lived leaf (90 days)',
|
|
],
|
|
'short_ca_needs_renewal' => [
|
|
'caExpiryDays' => 730,
|
|
'leafExpiryDays' => 90,
|
|
'ageInDays' => 650, // 80 days remaining
|
|
'needsRenewal' => true,
|
|
'description' => 'Short-lived CA needing renewal (80 days < 90 days)',
|
|
],
|
|
'very_close_to_expiry' => [
|
|
'caExpiryDays' => 3650,
|
|
'leafExpiryDays' => 365,
|
|
'ageInDays' => 3620, // 30 days remaining
|
|
'needsRenewal' => true,
|
|
'description' => 'Certificate very close to expiry (30 days < 365 days)',
|
|
],
|
|
'almost_expired' => [
|
|
'caExpiryDays' => 365,
|
|
'leafExpiryDays' => 90,
|
|
'ageInDays' => 357, // 8 days remaining
|
|
'needsRenewal' => true,
|
|
'description' => 'Certificate almost expired (8 days < 90 days)',
|
|
],
|
|
'long_leaf_short_ca' => [
|
|
'caExpiryDays' => 730, // 2 years CA
|
|
'leafExpiryDays' => 365, // 1 year leaf
|
|
'ageInDays' => 0,
|
|
'needsRenewal' => false,
|
|
'description' => 'Long-lived leaf (365 days) with 2-year CA (730 days)',
|
|
],
|
|
'ca_shorter_than_leaf' => [
|
|
'caExpiryDays' => 180, // 6 months CA
|
|
'leafExpiryDays' => 365, // 1 year leaf
|
|
'ageInDays' => 0,
|
|
'needsRenewal' => true, // CA will expire before leaf validity period
|
|
'description' => 'Edge case: CA (180 days) expires before leaf validity (365 days)',
|
|
],
|
|
];
|
|
}
|
|
|
|
private function simulateCertificateAging(OpenSslHandler $handler, int $ageInDays): void {
|
|
$configPath = $handler->getCurrentConfigPath();
|
|
$certPath = $configPath . '/ca.pem';
|
|
$keyPath = $configPath . '/ca-key.pem';
|
|
|
|
$cert = openssl_x509_read(file_get_contents($certPath));
|
|
$certData = openssl_x509_parse($cert);
|
|
$this->assertIsArray($certData, 'Failed to parse certificate');
|
|
$this->assertArrayHasKey('subject', $certData, 'Certificate must have subject field');
|
|
|
|
$privateKey = openssl_pkey_get_private(file_get_contents($keyPath));
|
|
|
|
$dn = is_array($certData['subject']) ? $certData['subject'] : $this->parseDnString($certData['subject']);
|
|
$this->assertIsArray($dn, 'DN must be an array');
|
|
|
|
$secondsPerDay = 60 * 60 * 24;
|
|
$originalValidity = (int)ceil(($certData['validTo_time_t'] - $certData['validFrom_time_t']) / $secondsPerDay);
|
|
$newValidity = $originalValidity - $ageInDays;
|
|
|
|
$this->assertGreaterThan(0, $newValidity, "Cannot age certificate by {$ageInDays} days - would be expired");
|
|
|
|
$csr = openssl_csr_new($dn, $privateKey, ['digest_alg' => 'sha256']);
|
|
$x509 = openssl_csr_sign($csr, null, $privateKey, $newValidity, [
|
|
'digest_alg' => 'sha256',
|
|
'config' => $configPath . '/openssl.cnf',
|
|
'x509_extensions' => 'v3_ca',
|
|
], random_int(1000000, PHP_INT_MAX));
|
|
|
|
openssl_x509_export($x509, $newCert);
|
|
file_put_contents($certPath, $newCert);
|
|
}
|
|
|
|
#[DataProvider('configureCheckExpiryProvider')]
|
|
public function testConfigureCheckExpiryWarnings(
|
|
int $caExpiryDays,
|
|
int $leafExpiryDays,
|
|
int $ageInDays,
|
|
?string $expectedLevel,
|
|
string $description,
|
|
): void {
|
|
$this->appConfig->setValueInt('libresign', 'ca_expiry_in_days', $caExpiryDays);
|
|
$this->appConfig->setValueInt('libresign', 'expiry_in_days', $leafExpiryDays);
|
|
|
|
$handler = $this->getInstance();
|
|
$handler->generateRootCert('Test Root CA for ' . $description, []);
|
|
|
|
if ($ageInDays > 0) {
|
|
$this->simulateCertificateAging($handler, $ageInDays);
|
|
}
|
|
|
|
$checks = $handler->configureCheck();
|
|
$this->assertIsArray($checks);
|
|
|
|
$expiryCheck = null;
|
|
foreach ($checks as $check) {
|
|
$checkArray = $check->jsonSerialize();
|
|
if (isset($checkArray['message']) && str_contains($checkArray['message'], 'expires')) {
|
|
$expiryCheck = $checkArray;
|
|
break;
|
|
}
|
|
if (isset($checkArray['message']) && str_contains($checkArray['message'], 'expired')) {
|
|
$expiryCheck = $checkArray;
|
|
break;
|
|
}
|
|
}
|
|
|
|
if ($expectedLevel === null) {
|
|
$this->assertNull($expiryCheck, 'No expiry warning expected for: ' . $description);
|
|
} else {
|
|
$this->assertNotNull($expiryCheck, 'Expiry warning expected for: ' . $description);
|
|
$this->assertEquals($expectedLevel, $expiryCheck['status'], 'Expected level: ' . $expectedLevel . ' for: ' . $description);
|
|
}
|
|
}
|
|
|
|
public static function configureCheckExpiryProvider(): array {
|
|
return [
|
|
'newly_created_no_warning' => [
|
|
'caExpiryDays' => 3650,
|
|
'leafExpiryDays' => 365,
|
|
'ageInDays' => 0,
|
|
'expectedLevel' => null,
|
|
'description' => 'Newly created - no warning',
|
|
],
|
|
'two_years_remaining_no_warning' => [
|
|
'caExpiryDays' => 3650,
|
|
'leafExpiryDays' => 365,
|
|
'ageInDays' => 2920,
|
|
'expectedLevel' => null,
|
|
'description' => '2 years remaining - no warning',
|
|
],
|
|
'at_leaf_validity_info' => [
|
|
'caExpiryDays' => 3650,
|
|
'leafExpiryDays' => 365,
|
|
'ageInDays' => 3285,
|
|
'expectedLevel' => 'info',
|
|
'description' => 'At leaf validity threshold - info',
|
|
],
|
|
'below_leaf_validity_info' => [
|
|
'caExpiryDays' => 3650,
|
|
'leafExpiryDays' => 365,
|
|
'ageInDays' => 3380,
|
|
'expectedLevel' => 'info',
|
|
'description' => 'Below leaf validity - info',
|
|
],
|
|
'29_days_warning' => [
|
|
'caExpiryDays' => 3650,
|
|
'leafExpiryDays' => 365,
|
|
'ageInDays' => 3621,
|
|
'expectedLevel' => 'error',
|
|
'description' => '29 days remaining - error',
|
|
],
|
|
'7_days_error' => [
|
|
'caExpiryDays' => 365,
|
|
'leafExpiryDays' => 90,
|
|
'ageInDays' => 358,
|
|
'expectedLevel' => 'error',
|
|
'description' => '7 days remaining - error',
|
|
],
|
|
'1_day_error' => [
|
|
'caExpiryDays' => 365,
|
|
'leafExpiryDays' => 90,
|
|
'ageInDays' => 364,
|
|
'expectedLevel' => 'error',
|
|
'description' => '1 day remaining - error',
|
|
],
|
|
];
|
|
}
|
|
}
|