Skip to content

Commit f5a8065

Browse files
authored
Merge pull request #7528 from LibreSign/fix/openssl-root-csr-error-7519
fix: stabilize root CSR generation on OpenSSL 3
2 parents 9627388 + 250fd18 commit f5a8065

2 files changed

Lines changed: 219 additions & 14 deletions

File tree

lib/Handler/CertificateEngine/OpenSslHandler.php

Lines changed: 105 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -33,6 +33,9 @@
3333
* @method CfsslHandler setClient(Client $client)
3434
*/
3535
class OpenSslHandler extends AEngineHandler implements IEngineHandler {
36+
/** @var list<string> */
37+
private array $lastOpenSslErrors = [];
38+
3639
public function __construct(
3740
protected IConfig $config,
3841
protected IAppConfig $appConfig,
@@ -71,13 +74,20 @@ public function generateRootCert(
7174
throw new EmptyCertificateException('Common Name (CN) cannot be empty for root certificate');
7275
}
7376

74-
$privateKey = openssl_pkey_new([
77+
$privateKey = $this->createPrivateKey([
7578
'private_key_bits' => 2048,
7679
'private_key_type' => OPENSSL_KEYTYPE_RSA,
7780
]);
81+
if ($privateKey === false) {
82+
throw $this->buildOpenSslException('Failed to generate OpenSSL private key for root certificate');
83+
}
7884

79-
$csr = openssl_csr_new($this->getCsrNames(), $privateKey, ['digest_alg' => 'sha256']);
80-
$options = $this->getRootCertOptions();
85+
$configFile = $this->generateCaConfig();
86+
$csr = $this->createCsr($this->getCsrNames(), $privateKey, $this->getRootCsrOptions($configFile));
87+
if ($csr === false) {
88+
throw $this->buildOpenSslException('Failed to generate OpenSSL CSR for root certificate');
89+
}
90+
$options = $this->getRootCertOptions($configFile);
8191

8292
$caDays = $this->getCaExpiryInDays();
8393

@@ -97,20 +107,36 @@ public function generateRootCert(
97107
);
98108
$serialNumber = (int)$serialNumberString;
99109

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

102-
openssl_csr_export($csr, $csrout);
103-
openssl_x509_export($x509, $certout);
104-
openssl_pkey_export($privateKey, $pkeyout);
115+
if (!openssl_csr_export($csr, $csrout)) {
116+
throw $this->buildOpenSslException('Failed to export OpenSSL root CSR');
117+
}
118+
if (!openssl_x509_export($x509, $certout)) {
119+
throw $this->buildOpenSslException('Failed to export OpenSSL root certificate');
120+
}
121+
if (!openssl_pkey_export($privateKey, $pkeyout)) {
122+
throw $this->buildOpenSslException('Failed to export OpenSSL private key');
123+
}
105124

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

112-
private function getRootCertOptions(): array {
113-
$configFile = $this->generateCaConfig();
131+
private function getRootCsrOptions(string $configFile): array {
132+
return [
133+
'digest_alg' => 'sha256',
134+
'config' => $configFile,
135+
'config_section_name' => 'req',
136+
];
137+
}
138+
139+
private function getRootCertOptions(string $configFile): array {
114140

115141
return [
116142
'digest_alg' => 'sha256',
@@ -142,15 +168,17 @@ public function generateCertificate(): string {
142168

143169
$this->inheritRootSubjectFields($rootCertificate);
144170

145-
$privateKey = openssl_pkey_new([
171+
$privateKey = $this->createPrivateKey([
146172
'private_key_bits' => 2048,
147173
'private_key_type' => OPENSSL_KEYTYPE_RSA,
148174
]);
175+
if ($privateKey === false) {
176+
throw $this->buildOpenSslException('Failed to generate OpenSSL private key');
177+
}
149178

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

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

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

178209
return parent::exportToPkcs12(
179210
$x509,
@@ -209,6 +240,54 @@ private function inheritRootSubjectFields(string $rootCertificate): void {
209240
}
210241
}
211242

243+
protected function createPrivateKey(array $options): mixed {
244+
return $this->runOpenSslOperation(static fn () => openssl_pkey_new($options));
245+
}
246+
247+
protected function createCsr(array $distinguishedNames, mixed $privateKey, array $options): mixed {
248+
return $this->runOpenSslOperation(static fn () => openssl_csr_new($distinguishedNames, $privateKey, $options));
249+
}
250+
251+
protected function signCsr(
252+
mixed $csr,
253+
mixed $caCertificate,
254+
mixed $privateKey,
255+
int $days,
256+
array $options,
257+
int $serialNumber,
258+
): mixed {
259+
return $this->runOpenSslOperation(static fn () => openssl_csr_sign($csr, $caCertificate, $privateKey, $days, $options, $serialNumber));
260+
}
261+
262+
private function runOpenSslOperation(callable $operation): mixed {
263+
$this->lastOpenSslErrors = [];
264+
265+
set_error_handler(function (int $severity, string $message): bool {
266+
$this->lastOpenSslErrors[] = $message;
267+
return true;
268+
});
269+
270+
try {
271+
return $operation();
272+
} finally {
273+
restore_error_handler();
274+
}
275+
}
276+
277+
private function buildOpenSslException(string $message): LibresignException {
278+
$errors = $this->lastOpenSslErrors;
279+
while (($error = openssl_error_string()) !== false) {
280+
$errors[] = $error;
281+
}
282+
$this->lastOpenSslErrors = [];
283+
284+
if (empty($errors)) {
285+
return new LibresignException($message . ': unknown OpenSSL error');
286+
}
287+
288+
return new LibresignException($message . ': ' . implode(' | ', $errors));
289+
}
290+
212291
private function generateCaConfig(): string {
213292
$config = $this->buildCaCertificateConfig();
214293
$this->cleanupCaConfig($config);
@@ -228,6 +307,12 @@ private function generateLeafConfig(): string {
228307
*/
229308
private function buildCaCertificateConfig(): array {
230309
$config = [
310+
'req' => [
311+
'distinguished_name' => 'req_distinguished_name',
312+
'x509_extensions' => 'v3_ca',
313+
'prompt' => 'no',
314+
],
315+
'req_distinguished_name' => [],
231316
'ca' => [
232317
'default_ca' => 'CA_default'
233318
],
@@ -267,6 +352,12 @@ private function buildCaCertificateConfig(): array {
267352

268353
private function buildLeafCertificateConfig(): array {
269354
$config = [
355+
'req' => [
356+
'distinguished_name' => 'req_distinguished_name',
357+
'req_extensions' => 'v3_req',
358+
'prompt' => 'no',
359+
],
360+
'req_distinguished_name' => [],
270361
'v3_req' => [
271362
'basicConstraints' => 'CA:FALSE',
272363
'keyUsage' => 'digitalSignature, keyEncipherment, nonRepudiation',

tests/php/Unit/Handler/CertificateEngine/OpenSslHandlerTest.php

Lines changed: 114 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -95,6 +95,103 @@ public function testEmptyCommonNameThrowsException(): void {
9595
$rootInstance->generateRootCert('', []);
9696
}
9797

98+
public function testRootCertificateCsrFailureThrowsLibresignException(): void {
99+
$rootInstance = $this->getMockBuilder(OpenSslHandler::class)
100+
->setConstructorArgs([
101+
$this->config,
102+
$this->appConfig,
103+
$this->appDataFactory,
104+
$this->dateTimeFormatter,
105+
$this->tempManager,
106+
$this->certificatePolicyService,
107+
$this->urlGenerator,
108+
$this->serialNumberService,
109+
$this->caIdentifierService,
110+
$this->logger,
111+
$this->crlMapper,
112+
$this->subjectAlternativeNameService,
113+
$this->crlRevocationChecker,
114+
])
115+
->onlyMethods(['createCsr'])
116+
->getMock();
117+
118+
$rootInstance->method('createCsr')->willReturn(false);
119+
120+
$this->expectException(LibresignException::class);
121+
$this->expectExceptionMessage('Failed to generate OpenSSL CSR for root certificate');
122+
$rootInstance->generateRootCert('Test Root CA', []);
123+
}
124+
125+
public function testRootCertificateSignFailureThrowsLibresignException(): void {
126+
$rootInstance = $this->getMockBuilder(OpenSslHandler::class)
127+
->setConstructorArgs([
128+
$this->config,
129+
$this->appConfig,
130+
$this->appDataFactory,
131+
$this->dateTimeFormatter,
132+
$this->tempManager,
133+
$this->certificatePolicyService,
134+
$this->urlGenerator,
135+
$this->serialNumberService,
136+
$this->caIdentifierService,
137+
$this->logger,
138+
$this->crlMapper,
139+
$this->subjectAlternativeNameService,
140+
$this->crlRevocationChecker,
141+
])
142+
->onlyMethods(['signCsr'])
143+
->getMock();
144+
145+
$rootInstance->method('signCsr')->willReturn(false);
146+
147+
$this->expectException(LibresignException::class);
148+
$this->expectExceptionMessage('Failed to sign OpenSSL root certificate');
149+
$rootInstance->generateRootCert('Test Root CA', []);
150+
}
151+
152+
public function testRootCertificateCsrUsesGeneratedConfig(): void {
153+
$rootInstance = $this->getMockBuilder(OpenSslHandler::class)
154+
->setConstructorArgs([
155+
$this->config,
156+
$this->appConfig,
157+
$this->appDataFactory,
158+
$this->dateTimeFormatter,
159+
$this->tempManager,
160+
$this->certificatePolicyService,
161+
$this->urlGenerator,
162+
$this->serialNumberService,
163+
$this->caIdentifierService,
164+
$this->logger,
165+
$this->crlMapper,
166+
$this->subjectAlternativeNameService,
167+
$this->crlRevocationChecker,
168+
])
169+
->onlyMethods(['createCsr', 'signCsr'])
170+
->getMock();
171+
172+
$rootInstance->method('createCsr')
173+
->willReturnCallback(function (array $distinguishedNames, mixed $privateKey, array $options) {
174+
$this->assertArrayHasKey('config', $options);
175+
$this->assertSame('req', $options['config_section_name']);
176+
$this->assertArrayNotHasKey('req_extensions', $options);
177+
$this->assertFileExists($options['config']);
178+
$configContent = file_get_contents($options['config']);
179+
$this->assertNotFalse($configContent);
180+
$this->assertStringContainsString('[req]', $configContent);
181+
$this->assertStringContainsString('distinguished_name = req_distinguished_name', $configContent);
182+
$this->assertStringContainsString('[req_distinguished_name]', $configContent);
183+
$this->assertStringContainsString('[v3_ca]', $configContent);
184+
185+
return openssl_csr_new($distinguishedNames, $privateKey, $options);
186+
});
187+
188+
$rootInstance->method('signCsr')->willReturn(false);
189+
190+
$this->expectException(LibresignException::class);
191+
$this->expectExceptionMessage('Failed to sign OpenSSL root certificate');
192+
$rootInstance->generateRootCert('Test Root CA', []);
193+
}
194+
98195
public function testInvalidPassword(): void {
99196
// Create root cert
100197
$rootInstance = $this->getInstance();
@@ -141,6 +238,23 @@ public function testBiggerThanMaxLengthOfDistinguishedNamesWithError(): void {
141238
$signerInstance->generateCertificate();
142239
}
143240

241+
public function testRealOpenSslFailureIncludesDiagnosticMessage(): void {
242+
$rootInstance = $this->getInstance();
243+
$rootInstance->generateRootCert('Test Root CA', []);
244+
245+
$signerInstance = $this->getInstance();
246+
$signerInstance->setCommonName(str_repeat('a', 65));
247+
$signerInstance->setPassword('123456');
248+
249+
try {
250+
$signerInstance->generateCertificate();
251+
$this->fail('Expected LibresignException was not thrown.');
252+
} catch (LibresignException $exception) {
253+
$this->assertStringContainsString('Failed to generate OpenSSL CSR', $exception->getMessage());
254+
$this->assertMatchesRegularExpression('/string too long|add_entry_by_NID/i', $exception->getMessage());
255+
}
256+
}
257+
144258
/**
145259
* @dataProvider dataReadCertificate
146260
*/

0 commit comments

Comments
 (0)