• Home
  • Features
  • Pricing
  • Docs
  • Announcements
  • Sign In

LibreSign / libresign / 24732694125

21 Apr 2026 03:59PM UTC coverage: 56.349%. First build
24732694125

Pull #7528

github

web-flow
Merge 250fd189a into 962738825
Pull Request #7528: fix: stabilize root CSR generation on OpenSSL 3

52 of 59 new or added lines in 1 file covered. (88.14%)

10509 of 18650 relevant lines covered (56.35%)

6.71 hits per line

Source File
Press 'n' to go to next uncovered line, 'b' for previous

91.64
/lib/Handler/CertificateEngine/OpenSslHandler.php
1
<?php
2

3
declare(strict_types=1);
4
/**
5
 * SPDX-FileCopyrightText: 2020-2024 LibreCode coop and contributors
6
 * SPDX-License-Identifier: AGPL-3.0-or-later
7
 */
8

9
namespace OCA\Libresign\Handler\CertificateEngine;
10

11
use OCA\Libresign\Db\CrlMapper;
12
use OCA\Libresign\Enum\CertificateType;
13
use OCA\Libresign\Exception\EmptyCertificateException;
14
use OCA\Libresign\Exception\LibresignException;
15
use OCA\Libresign\Service\CaIdentifierService;
16
use OCA\Libresign\Service\CertificatePolicyService;
17
use OCA\Libresign\Service\Crl\CrlRevocationChecker;
18
use OCA\Libresign\Service\SerialNumberService;
19
use OCA\Libresign\Service\SubjectAlternativeNameService;
20
use OCP\Files\AppData\IAppDataFactory;
21
use OCP\IAppConfig;
22
use OCP\IConfig;
23
use OCP\IDateTimeFormatter;
24
use OCP\ITempManager;
25
use OCP\IURLGenerator;
26
use Psr\Log\LoggerInterface;
27

28
/**
29
 * Class FileMapper
30
 *
31
 * @package OCA\Libresign\Handler
32
 *
33
 * @method CfsslHandler setClient(Client $client)
34
 */
35
class OpenSslHandler extends AEngineHandler implements IEngineHandler {
36
        /** @var list<string> */
37
        private array $lastOpenSslErrors = [];
38

39
        public function __construct(
40
                protected IConfig $config,
41
                protected IAppConfig $appConfig,
42
                protected IAppDataFactory $appDataFactory,
43
                protected IDateTimeFormatter $dateTimeFormatter,
44
                protected ITempManager $tempManager,
45
                protected CertificatePolicyService $certificatePolicyService,
46
                protected IURLGenerator $urlGenerator,
47
                protected SerialNumberService $serialNumberService,
48
                protected CaIdentifierService $caIdentifierService,
49
                protected LoggerInterface $logger,
50
                protected CrlMapper $crlMapper,
51
                protected SubjectAlternativeNameService $subjectAlternativeNameService,
52
                CrlRevocationChecker $crlRevocationChecker,
53
        ) {
54
                parent::__construct(
145✔
55
                        $config,
145✔
56
                        $appConfig,
145✔
57
                        $appDataFactory,
145✔
58
                        $dateTimeFormatter,
145✔
59
                        $tempManager,
145✔
60
                        $certificatePolicyService,
145✔
61
                        $urlGenerator,
145✔
62
                        $caIdentifierService,
145✔
63
                        $logger,
145✔
64
                        $crlRevocationChecker,
145✔
65
                );
145✔
66
        }
67

68
        #[\Override]
69
        public function generateRootCert(
70
                string $commonName,
71
                array $names = [],
72
        ): void {
73
                if (empty($commonName)) {
64✔
74
                        throw new EmptyCertificateException('Common Name (CN) cannot be empty for root certificate');
1✔
75
                }
76

77
                $privateKey = $this->createPrivateKey([
63✔
78
                        'private_key_bits' => 2048,
63✔
79
                        'private_key_type' => OPENSSL_KEYTYPE_RSA,
63✔
80
                ]);
63✔
81
                if ($privateKey === false) {
63✔
NEW
82
                        throw $this->buildOpenSslException('Failed to generate OpenSSL private key for root certificate');
×
83
                }
84

85
                $configFile = $this->generateCaConfig();
63✔
86
                $csr = $this->createCsr($this->getCsrNames(), $privateKey, $this->getRootCsrOptions($configFile));
63✔
87
                if ($csr === false) {
63✔
88
                        throw $this->buildOpenSslException('Failed to generate OpenSSL CSR for root certificate');
1✔
89
                }
90
                $options = $this->getRootCertOptions($configFile);
62✔
91

92
                $caDays = $this->getCaExpiryInDays();
62✔
93

94
                $subject = parent::getNames();
62✔
95
                $subject['CN'] = $commonName;
62✔
96
                $issuer = $subject;
62✔
97

98
                $serialNumberString = $this->serialNumberService->generateUniqueSerial(
62✔
99
                        $commonName,
62✔
100
                        $this->caIdentifierService->getInstanceId(),
62✔
101
                        $this->caIdentifierService->getCaIdParsed()['generation'],
62✔
102
                        new \DateTime('+' . $caDays . ' days'),
62✔
103
                        'openssl',
62✔
104
                        $issuer,
62✔
105
                        $subject,
62✔
106
                        CertificateType::ROOT->value,
62✔
107
                );
62✔
108
                $serialNumber = (int)$serialNumberString;
62✔
109

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

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

125
                $configPath = $this->getCurrentConfigPath();
60✔
126
                CertificateHelper::saveFile($configPath . '/ca.csr', $csrout);
60✔
127
                CertificateHelper::saveFile($configPath . '/ca.pem', $certout);
60✔
128
                CertificateHelper::saveFile($configPath . '/ca-key.pem', $pkeyout);
60✔
129
        }
130

131
        private function getRootCsrOptions(string $configFile): array {
132
                return [
63✔
133
                        'digest_alg' => 'sha256',
63✔
134
                        'config' => $configFile,
63✔
135
                        'config_section_name' => 'req',
63✔
136
                ];
63✔
137
        }
138

139
        private function getRootCertOptions(string $configFile): array {
140

141
                return [
62✔
142
                        'digest_alg' => 'sha256',
62✔
143
                        'config' => $configFile,
62✔
144
                        'x509_extensions' => 'v3_ca',
62✔
145
                ];
62✔
146
        }
147

148
        private function getLeafCertOptions(): array {
149
                $configFile = $this->generateLeafConfig();
8✔
150

151
                return [
8✔
152
                        'digest_alg' => 'sha256',
8✔
153
                        'config' => $configFile,
8✔
154
                        'x509_extensions' => 'v3_req',
8✔
155
                ];
8✔
156
        }
157

158
        #[\Override]
159
        public function generateCertificate(): string {
160
                $this->validateRootCertificate();
10✔
161

162
                $configPath = $this->getCurrentConfigPath();
10✔
163
                $rootCertificate = file_get_contents($configPath . DIRECTORY_SEPARATOR . 'ca.pem');
10✔
164
                $rootPrivateKey = file_get_contents($configPath . DIRECTORY_SEPARATOR . 'ca-key.pem');
10✔
165
                if (empty($rootCertificate) || empty($rootPrivateKey)) {
10✔
166
                        throw new LibresignException('Invalid root certificate');
×
167
                }
168

169
                $this->inheritRootSubjectFields($rootCertificate);
10✔
170

171
                $privateKey = $this->createPrivateKey([
10✔
172
                        'private_key_bits' => 2048,
10✔
173
                        'private_key_type' => OPENSSL_KEYTYPE_RSA,
10✔
174
                ]);
10✔
175
                if ($privateKey === false) {
10✔
NEW
176
                        throw $this->buildOpenSslException('Failed to generate OpenSSL private key');
×
177
                }
178

179
                $csr = $this->createCsr($this->getCsrNames(), $privateKey, ['digest_alg' => 'sha256']);
10✔
180
                if ($csr === false) {
10✔
181
                        throw $this->buildOpenSslException('Failed to generate OpenSSL CSR');
2✔
182
                }
183

184
                $parsedRoot = openssl_x509_parse($rootCertificate);
8✔
185
                /** @var array<string, mixed> $issuer */
186
                $issuer = $parsedRoot['subject'] ?? [];
8✔
187

188
                $subject = parent::getNames();
8✔
189
                $subject['CN'] = $this->getCommonName();
8✔
190

191
                $serialNumberString = $this->serialNumberService->generateUniqueSerial(
8✔
192
                        $this->getCommonName(),
8✔
193
                        $this->caIdentifierService->getInstanceId(),
8✔
194
                        $this->caIdentifierService->getCaIdParsed()['generation'],
8✔
195
                        new \DateTime('+' . $this->getLeafExpiryInDays() . ' days'),
8✔
196
                        'openssl',
8✔
197
                        $issuer,
8✔
198
                        $subject,
8✔
199
                        CertificateType::LEAF->value,
8✔
200
                );
8✔
201
                $serialNumber = (int)$serialNumberString;
8✔
202
                $options = $this->getLeafCertOptions();
8✔
203

204
                $x509 = $this->signCsr($csr, $rootCertificate, $rootPrivateKey, $this->getLeafExpiryInDays(), $options, $serialNumber);
8✔
205
                if ($x509 === false) {
8✔
NEW
206
                        throw $this->buildOpenSslException('Failed to sign OpenSSL certificate');
×
207
                }
208

209
                return parent::exportToPkcs12(
8✔
210
                        $x509,
8✔
211
                        $privateKey,
8✔
212
                        [
8✔
213
                                'friendly_name' => $this->getFriendlyName(),
8✔
214
                                'extracerts' => [
8✔
215
                                        $x509,
8✔
216
                                        $rootCertificate,
8✔
217
                                ],
8✔
218
                        ],
8✔
219
                );
8✔
220
        }
221

222
        private function inheritRootSubjectFields(string $rootCertificate): void {
223
                $parsedRoot = openssl_x509_parse($rootCertificate);
10✔
224
                if ($parsedRoot && isset($parsedRoot['subject']) && is_array($parsedRoot['subject'])) {
10✔
225
                        $map = [
10✔
226
                                'C' => 'country',
10✔
227
                                'ST' => 'state',
10✔
228
                                'L' => 'locality',
10✔
229
                                'O' => 'organization',
10✔
230
                                'OU' => 'organizationalUnit',
10✔
231
                        ];
10✔
232
                        foreach ($parsedRoot['subject'] as $k => $v) {
10✔
233
                                if (isset($map[$k])) {
2✔
234
                                        $setter = 'set' . ucfirst($map[$k]);
2✔
235
                                        if (method_exists($this, $setter)) {
2✔
236
                                                $this->$setter($v);
×
237
                                        }
238
                                }
239
                        }
240
                }
241
        }
242

243
        protected function createPrivateKey(array $options): mixed {
244
                return $this->runOpenSslOperation(static fn () => openssl_pkey_new($options));
63✔
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));
61✔
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));
60✔
260
        }
261

262
        private function runOpenSslOperation(callable $operation): mixed {
263
                $this->lastOpenSslErrors = [];
63✔
264

265
                set_error_handler(function (int $severity, string $message): bool {
63✔
266
                        $this->lastOpenSslErrors[] = $message;
2✔
267
                        return true;
2✔
268
                });
63✔
269

270
                try {
271
                        return $operation();
63✔
272
                } finally {
273
                        restore_error_handler();
63✔
274
                }
275
        }
276

277
        private function buildOpenSslException(string $message): LibresignException {
278
                $errors = $this->lastOpenSslErrors;
5✔
279
                while (($error = openssl_error_string()) !== false) {
5✔
280
                        $errors[] = $error;
5✔
281
                }
282
                $this->lastOpenSslErrors = [];
5✔
283

284
                if (empty($errors)) {
5✔
NEW
285
                        return new LibresignException($message . ': unknown OpenSSL error');
×
286
                }
287

288
                return new LibresignException($message . ': ' . implode(' | ', $errors));
5✔
289
        }
290

291
        private function generateCaConfig(): string {
292
                $config = $this->buildCaCertificateConfig();
63✔
293
                $this->cleanupCaConfig($config);
63✔
294

295
                return $this->saveCaConfigFile($config);
63✔
296
        }
297

298
        private function generateLeafConfig(): string {
299
                $config = $this->buildLeafCertificateConfig();
8✔
300
                $this->cleanupLeafConfig($config);
8✔
301

302
                return $this->saveLeafConfigFile($config);
8✔
303
        }
304

305
        /**
306
         * More information about x509v3: https://www.openssl.org/docs/manmaster/man5/x509v3_config.html
307
         */
308
        private function buildCaCertificateConfig(): array {
309
                $config = [
63✔
310
                        'req' => [
63✔
311
                                'distinguished_name' => 'req_distinguished_name',
63✔
312
                                'x509_extensions' => 'v3_ca',
63✔
313
                                'prompt' => 'no',
63✔
314
                        ],
63✔
315
                        'req_distinguished_name' => [],
63✔
316
                        'ca' => [
63✔
317
                                'default_ca' => 'CA_default'
63✔
318
                        ],
63✔
319
                        'CA_default' => [
63✔
320
                                'default_crl_days' => 7,
63✔
321
                                'default_md' => 'sha256',
63✔
322
                                'preserve' => 'no',
63✔
323
                                'policy' => 'policy_anything'
63✔
324
                        ],
63✔
325
                        'policy_anything' => [
63✔
326
                                'countryName' => 'optional',
63✔
327
                                'stateOrProvinceName' => 'optional',
63✔
328
                                'organizationName' => 'optional',
63✔
329
                                'organizationalUnitName' => 'optional',
63✔
330
                                'commonName' => 'supplied',
63✔
331
                                'emailAddress' => 'optional'
63✔
332
                        ],
63✔
333
                        'v3_ca' => [
63✔
334
                                'basicConstraints' => 'critical, CA:TRUE, pathlen:1',
63✔
335
                                'keyUsage' => 'critical, digitalSignature, keyCertSign, cRLSign',
63✔
336
                                'subjectAltName' => $this->getSubjectAltNames(),
63✔
337
                                'authorityKeyIdentifier' => 'keyid',
63✔
338
                                'subjectKeyIdentifier' => 'hash',
63✔
339
                                'crlDistributionPoints' => 'URI:' . $this->getCrlDistributionUrl(),
63✔
340
                        ],
63✔
341
                        'crl_ext' => [
63✔
342
                                'issuerAltName' => 'issuer:copy',
63✔
343
                                'authorityKeyIdentifier' => 'keyid:always',
63✔
344
                                'subjectKeyIdentifier' => 'hash'
63✔
345
                        ]
63✔
346
                ];
63✔
347

348
                $this->addCaPolicies($config);
63✔
349

350
                return $config;
63✔
351
        }
352

353
        private function buildLeafCertificateConfig(): array {
354
                $config = [
8✔
355
                        'req' => [
8✔
356
                                'distinguished_name' => 'req_distinguished_name',
8✔
357
                                'req_extensions' => 'v3_req',
8✔
358
                                'prompt' => 'no',
8✔
359
                        ],
8✔
360
                        'req_distinguished_name' => [],
8✔
361
                        'v3_req' => [
8✔
362
                                'basicConstraints' => 'CA:FALSE',
8✔
363
                                'keyUsage' => 'digitalSignature, keyEncipherment, nonRepudiation',
8✔
364
                                'extendedKeyUsage' => 'clientAuth, emailProtection',
8✔
365
                                'subjectAltName' => $this->getSubjectAltNames(),
8✔
366
                                'authorityKeyIdentifier' => 'keyid:always,issuer:always',
8✔
367
                                'subjectKeyIdentifier' => 'hash',
8✔
368
                                'crlDistributionPoints' => 'URI:' . $this->getCrlDistributionUrl(),
8✔
369
                        ],
8✔
370
                ];
8✔
371

372
                $this->addLeafPolicies($config);
8✔
373

374
                return $config;
8✔
375
        }
376

377
        private function addCaPolicies(array &$config): void {
378
                $oid = $this->certificatePolicyService->getOid();
63✔
379
                $cps = $this->certificatePolicyService->getCps();
63✔
380

381
                if (!$oid || !$cps) {
63✔
382
                        return;
63✔
383
                }
384

385
                $config['v3_ca']['certificatePolicies'] = '@policy_section';
×
386
                $config['policy_section'] = [
×
387
                        'policyIdentifier' => $oid,
×
388
                        'CPS.1' => $cps,
×
389
                ];
×
390
        }
391

392
        private function addLeafPolicies(array &$config): void {
393
                $oid = $this->certificatePolicyService->getOid();
8✔
394
                $cps = $this->certificatePolicyService->getCps();
8✔
395

396
                if (!$oid || !$cps) {
8✔
397
                        return;
8✔
398
                }
399

400
                $config['v3_req']['certificatePolicies'] = '@policy_section';
×
401
                $config['policy_section'] = [
×
402
                        'policyIdentifier' => $oid,
×
403
                        'CPS.1' => $cps,
×
404
                ];
×
405
        }
406

407
        private function cleanupCaConfig(array &$config): void {
408
                if (empty($config['v3_ca']['subjectAltName'])) {
63✔
409
                        unset($config['v3_ca']['subjectAltName']);
63✔
410
                }
411
        }
412

413
        private function cleanupLeafConfig(array &$config): void {
414
                if (empty($config['v3_req']['subjectAltName'])) {
8✔
415
                        unset($config['v3_req']['subjectAltName']);
5✔
416
                }
417
        }
418

419
        private function saveCaConfigFile(array $config): string {
420
                $iniContent = CertificateHelper::arrayToIni($config);
63✔
421
                $configFile = $this->getCurrentConfigPath() . '/openssl.cnf';
63✔
422
                CertificateHelper::saveFile($configFile, $iniContent);
63✔
423
                return $configFile;
63✔
424
        }
425

426
        private function saveLeafConfigFile(array $config): string {
427
                $iniContent = CertificateHelper::arrayToIni($config);
8✔
428
                $temporaryFile = $this->tempManager->getTemporaryFile('.cfg');
8✔
429
                if (!$temporaryFile) {
8✔
430
                        throw new LibresignException('Failure to create temporary file to OpenSSL .cfg file');
×
431
                }
432
                file_put_contents($temporaryFile, $iniContent);
8✔
433
                return $temporaryFile;
8✔
434
        }
435

436
        private function getSubjectAltNames(): string {
437
                $hosts = $this->getHosts();
63✔
438
                return $this->subjectAlternativeNameService->buildForHosts($hosts);
63✔
439
        }
440

441
        /**
442
         * Convert to names as necessary to OpenSSL
443
         *
444
         * Read more here: https://www.php.net/manual/en/function.openssl-csr-new.php
445
         */
446
        private function getCsrNames(): array {
447
                $distinguishedNames = [];
63✔
448
                $names = parent::getNames();
63✔
449
                foreach ($names as $name => $value) {
63✔
450
                        if ($name === 'ST') {
3✔
451
                                $distinguishedNames['stateOrProvinceName'] = $value;
3✔
452
                                continue;
3✔
453
                        }
454
                        if ($name === 'UID') {
3✔
455
                                $distinguishedNames['UID'] = $value;
×
456
                                continue;
×
457
                        }
458

459
                        $longName = $this->translateToLong($name);
3✔
460
                        $longName = lcfirst($longName) . 'Name';
3✔
461

462
                        if (is_array($value)) {
3✔
463
                                if (!empty($value)) {
2✔
464
                                        $distinguishedNames[$longName] = implode(', ', $value);
2✔
465
                                }
466
                        } else {
467
                                $distinguishedNames[$longName] = $value;
3✔
468
                        }
469
                }
470
                if ($this->getCommonName()) {
63✔
471
                        $distinguishedNames['commonName'] = $this->getCommonName();
10✔
472
                }
473
                return $distinguishedNames;
63✔
474
        }
475

476
        #[\Override]
477
        public function isSetupOk(): bool {
478
                $configPath = $this->getCurrentConfigPath();
19✔
479
                if (empty($configPath)) {
19✔
480
                        return false;
×
481
                }
482
                $certificate = file_exists($configPath . DIRECTORY_SEPARATOR . 'ca.pem');
19✔
483
                $privateKey = file_exists($configPath . DIRECTORY_SEPARATOR . 'ca-key.pem');
19✔
484
                return $certificate && $privateKey;
19✔
485
        }
486

487
        #[\Override]
488
        protected function getConfigureCheckResourceName(): string {
489
                return 'openssl-configure';
8✔
490
        }
491

492
        #[\Override]
493
        protected function getCertificateRegenerationTip(): string {
494
                return 'Consider regenerating the root certificate with: occ libresign:configure:openssl --cn="Your CA Name"';
7✔
495
        }
496

497
        #[\Override]
498
        protected function getEngineSpecificChecks(): array {
499
                return [];
8✔
500
        }
501

502
        #[\Override]
503
        protected function getSetupSuccessMessage(): string {
504
                return 'Root certificate setup is working fine.';
7✔
505
        }
506

507
        #[\Override]
508
        protected function getSetupErrorMessage(): string {
509
                return 'OpenSSL (root certificate) not configured.';
1✔
510
        }
511

512
        #[\Override]
513
        protected function getSetupErrorTip(): string {
514
                return 'Run occ libresign:configure:openssl --help';
1✔
515
        }
516
}
STATUS · Troubleshooting · Open an Issue · Sales · Support · CAREERS · ENTERPRISE · START FREE · SCHEDULE DEMO
ANNOUNCEMENTS · TWITTER · TOS & SLA · Supported CI Services · What's a CI service? · Automated Testing

© 2026 Coveralls, Inc