Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
119 changes: 105 additions & 14 deletions lib/Handler/CertificateEngine/OpenSslHandler.php
Original file line number Diff line number Diff line change
Expand Up @@ -33,6 +33,9 @@
* @method CfsslHandler setClient(Client $client)
*/
class OpenSslHandler extends AEngineHandler implements IEngineHandler {
/** @var list<string> */
private array $lastOpenSslErrors = [];

public function __construct(
protected IConfig $config,
protected IAppConfig $appConfig,
Expand Down Expand Up @@ -71,13 +74,20 @@ public function generateRootCert(
throw new EmptyCertificateException('Common Name (CN) cannot be empty for root certificate');
}

$privateKey = openssl_pkey_new([
$privateKey = $this->createPrivateKey([
'private_key_bits' => 2048,
'private_key_type' => OPENSSL_KEYTYPE_RSA,
]);
if ($privateKey === false) {
throw $this->buildOpenSslException('Failed to generate OpenSSL private key for root certificate');
}

$csr = openssl_csr_new($this->getCsrNames(), $privateKey, ['digest_alg' => 'sha256']);
$options = $this->getRootCertOptions();
$configFile = $this->generateCaConfig();
$csr = $this->createCsr($this->getCsrNames(), $privateKey, $this->getRootCsrOptions($configFile));
if ($csr === false) {
throw $this->buildOpenSslException('Failed to generate OpenSSL CSR for root certificate');
}
$options = $this->getRootCertOptions($configFile);

$caDays = $this->getCaExpiryInDays();

Expand All @@ -97,20 +107,36 @@ public function generateRootCert(
);
$serialNumber = (int)$serialNumberString;

$x509 = openssl_csr_sign($csr, null, $privateKey, $caDays, $options, $serialNumber);
$x509 = $this->signCsr($csr, null, $privateKey, $caDays, $options, $serialNumber);
if ($x509 === false) {
throw $this->buildOpenSslException('Failed to sign OpenSSL root certificate');
}

openssl_csr_export($csr, $csrout);
openssl_x509_export($x509, $certout);
openssl_pkey_export($privateKey, $pkeyout);
if (!openssl_csr_export($csr, $csrout)) {
throw $this->buildOpenSslException('Failed to export OpenSSL root CSR');
}
if (!openssl_x509_export($x509, $certout)) {
throw $this->buildOpenSslException('Failed to export OpenSSL root certificate');
}
if (!openssl_pkey_export($privateKey, $pkeyout)) {
throw $this->buildOpenSslException('Failed to export OpenSSL private key');
}

$configPath = $this->getCurrentConfigPath();
CertificateHelper::saveFile($configPath . '/ca.csr', $csrout);
CertificateHelper::saveFile($configPath . '/ca.pem', $certout);
CertificateHelper::saveFile($configPath . '/ca-key.pem', $pkeyout);
}

private function getRootCertOptions(): array {
$configFile = $this->generateCaConfig();
private function getRootCsrOptions(string $configFile): array {
return [
'digest_alg' => 'sha256',
'config' => $configFile,
'config_section_name' => 'req',
];
}

private function getRootCertOptions(string $configFile): array {

return [
'digest_alg' => 'sha256',
Expand Down Expand Up @@ -142,15 +168,17 @@ public function generateCertificate(): string {

$this->inheritRootSubjectFields($rootCertificate);

$privateKey = openssl_pkey_new([
$privateKey = $this->createPrivateKey([
'private_key_bits' => 2048,
'private_key_type' => OPENSSL_KEYTYPE_RSA,
]);
if ($privateKey === false) {
throw $this->buildOpenSslException('Failed to generate OpenSSL private key');
}

$csr = @openssl_csr_new($this->getCsrNames(), $privateKey, ['digest_alg' => 'sha256']);
$csr = $this->createCsr($this->getCsrNames(), $privateKey, ['digest_alg' => 'sha256']);
if ($csr === false) {
$message = openssl_error_string();
throw new LibresignException('OpenSSL error: ' . $message);
throw $this->buildOpenSslException('Failed to generate OpenSSL CSR');
}

$parsedRoot = openssl_x509_parse($rootCertificate);
Expand All @@ -173,7 +201,10 @@ public function generateCertificate(): string {
$serialNumber = (int)$serialNumberString;
$options = $this->getLeafCertOptions();

$x509 = openssl_csr_sign($csr, $rootCertificate, $rootPrivateKey, $this->getLeafExpiryInDays(), $options, $serialNumber);
$x509 = $this->signCsr($csr, $rootCertificate, $rootPrivateKey, $this->getLeafExpiryInDays(), $options, $serialNumber);
if ($x509 === false) {
throw $this->buildOpenSslException('Failed to sign OpenSSL certificate');
}

return parent::exportToPkcs12(
$x509,
Expand Down Expand Up @@ -209,6 +240,54 @@ private function inheritRootSubjectFields(string $rootCertificate): void {
}
}

protected function createPrivateKey(array $options): mixed {
return $this->runOpenSslOperation(static fn () => openssl_pkey_new($options));
}

protected function createCsr(array $distinguishedNames, mixed $privateKey, array $options): mixed {
return $this->runOpenSslOperation(static fn () => openssl_csr_new($distinguishedNames, $privateKey, $options));
}

protected function signCsr(
mixed $csr,
mixed $caCertificate,
mixed $privateKey,
int $days,
array $options,
int $serialNumber,
): mixed {
return $this->runOpenSslOperation(static fn () => openssl_csr_sign($csr, $caCertificate, $privateKey, $days, $options, $serialNumber));
}

private function runOpenSslOperation(callable $operation): mixed {
$this->lastOpenSslErrors = [];

set_error_handler(function (int $severity, string $message): bool {
$this->lastOpenSslErrors[] = $message;
return true;
});

try {
return $operation();
} finally {
restore_error_handler();
}
}

private function buildOpenSslException(string $message): LibresignException {
$errors = $this->lastOpenSslErrors;
while (($error = openssl_error_string()) !== false) {
$errors[] = $error;
}
$this->lastOpenSslErrors = [];

if (empty($errors)) {
return new LibresignException($message . ': unknown OpenSSL error');
}

return new LibresignException($message . ': ' . implode(' | ', $errors));
}

private function generateCaConfig(): string {
$config = $this->buildCaCertificateConfig();
$this->cleanupCaConfig($config);
Expand All @@ -228,6 +307,12 @@ private function generateLeafConfig(): string {
*/
private function buildCaCertificateConfig(): array {
$config = [
'req' => [
'distinguished_name' => 'req_distinguished_name',
'x509_extensions' => 'v3_ca',
'prompt' => 'no',
],
'req_distinguished_name' => [],
'ca' => [
'default_ca' => 'CA_default'
],
Expand Down Expand Up @@ -267,6 +352,12 @@ private function buildCaCertificateConfig(): array {

private function buildLeafCertificateConfig(): array {
$config = [
'req' => [
'distinguished_name' => 'req_distinguished_name',
'req_extensions' => 'v3_req',
'prompt' => 'no',
],
'req_distinguished_name' => [],
'v3_req' => [
'basicConstraints' => 'CA:FALSE',
'keyUsage' => 'digitalSignature, keyEncipherment, nonRepudiation',
Expand Down
114 changes: 114 additions & 0 deletions tests/php/Unit/Handler/CertificateEngine/OpenSslHandlerTest.php
Original file line number Diff line number Diff line change
Expand Up @@ -95,6 +95,103 @@ public function testEmptyCommonNameThrowsException(): void {
$rootInstance->generateRootCert('', []);
}

public function testRootCertificateCsrFailureThrowsLibresignException(): void {
$rootInstance = $this->getMockBuilder(OpenSslHandler::class)
->setConstructorArgs([
$this->config,
$this->appConfig,
$this->appDataFactory,
$this->dateTimeFormatter,
$this->tempManager,
$this->certificatePolicyService,
$this->urlGenerator,
$this->serialNumberService,
$this->caIdentifierService,
$this->logger,
$this->crlMapper,
$this->subjectAlternativeNameService,
$this->crlRevocationChecker,
])
->onlyMethods(['createCsr'])
->getMock();

$rootInstance->method('createCsr')->willReturn(false);

$this->expectException(LibresignException::class);
$this->expectExceptionMessage('Failed to generate OpenSSL CSR for root certificate');
$rootInstance->generateRootCert('Test Root CA', []);
}

public function testRootCertificateSignFailureThrowsLibresignException(): void {
$rootInstance = $this->getMockBuilder(OpenSslHandler::class)
->setConstructorArgs([
$this->config,
$this->appConfig,
$this->appDataFactory,
$this->dateTimeFormatter,
$this->tempManager,
$this->certificatePolicyService,
$this->urlGenerator,
$this->serialNumberService,
$this->caIdentifierService,
$this->logger,
$this->crlMapper,
$this->subjectAlternativeNameService,
$this->crlRevocationChecker,
])
->onlyMethods(['signCsr'])
->getMock();

$rootInstance->method('signCsr')->willReturn(false);

$this->expectException(LibresignException::class);
$this->expectExceptionMessage('Failed to sign OpenSSL root certificate');
$rootInstance->generateRootCert('Test Root CA', []);
}

public function testRootCertificateCsrUsesGeneratedConfig(): void {
$rootInstance = $this->getMockBuilder(OpenSslHandler::class)
->setConstructorArgs([
$this->config,
$this->appConfig,
$this->appDataFactory,
$this->dateTimeFormatter,
$this->tempManager,
$this->certificatePolicyService,
$this->urlGenerator,
$this->serialNumberService,
$this->caIdentifierService,
$this->logger,
$this->crlMapper,
$this->subjectAlternativeNameService,
$this->crlRevocationChecker,
])
->onlyMethods(['createCsr', 'signCsr'])
->getMock();

$rootInstance->method('createCsr')
->willReturnCallback(function (array $distinguishedNames, mixed $privateKey, array $options) {
$this->assertArrayHasKey('config', $options);
$this->assertSame('req', $options['config_section_name']);
$this->assertArrayNotHasKey('req_extensions', $options);
$this->assertFileExists($options['config']);
$configContent = file_get_contents($options['config']);
$this->assertNotFalse($configContent);
$this->assertStringContainsString('[req]', $configContent);
$this->assertStringContainsString('distinguished_name = req_distinguished_name', $configContent);
$this->assertStringContainsString('[req_distinguished_name]', $configContent);
$this->assertStringContainsString('[v3_ca]', $configContent);

return openssl_csr_new($distinguishedNames, $privateKey, $options);
});

$rootInstance->method('signCsr')->willReturn(false);

$this->expectException(LibresignException::class);
$this->expectExceptionMessage('Failed to sign OpenSSL root certificate');
$rootInstance->generateRootCert('Test Root CA', []);
}

public function testInvalidPassword(): void {
// Create root cert
$rootInstance = $this->getInstance();
Expand Down Expand Up @@ -141,6 +238,23 @@ public function testBiggerThanMaxLengthOfDistinguishedNamesWithError(): void {
$signerInstance->generateCertificate();
}

public function testRealOpenSslFailureIncludesDiagnosticMessage(): void {
$rootInstance = $this->getInstance();
$rootInstance->generateRootCert('Test Root CA', []);

$signerInstance = $this->getInstance();
$signerInstance->setCommonName(str_repeat('a', 65));
$signerInstance->setPassword('123456');

try {
$signerInstance->generateCertificate();
$this->fail('Expected LibresignException was not thrown.');
} catch (LibresignException $exception) {
$this->assertStringContainsString('Failed to generate OpenSSL CSR', $exception->getMessage());
$this->assertMatchesRegularExpression('/string too long|add_entry_by_NID/i', $exception->getMessage());
}
}

/**
* @dataProvider dataReadCertificate
*/
Expand Down
Loading