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

LibreSign / libresign / 19117175691

05 Nov 2025 09:40PM UTC coverage: 39.847%. First build
19117175691

Pull #5754

github

web-flow
Merge 499423ca1 into 681cce019
Pull Request #5754: feat: preserve previous root cert

86 of 120 new or added lines in 10 files covered. (71.67%)

4633 of 11627 relevant lines covered (39.85%)

3.06 hits per line

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

83.33
/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\Exception\LibresignException;
12
use OCA\Libresign\Service\CaIdentifierService;
13
use OCA\Libresign\Service\CertificatePolicyService;
14
use OCA\Libresign\Service\SerialNumberService;
15
use OCP\Files\AppData\IAppDataFactory;
16
use OCP\IAppConfig;
17
use OCP\IConfig;
18
use OCP\IDateTimeFormatter;
19
use OCP\ITempManager;
20
use OCP\IURLGenerator;
21

22
/**
23
 * Class FileMapper
24
 *
25
 * @package OCA\Libresign\Handler
26
 *
27
 * @method CfsslHandler setClient(Client $client)
28
 */
29
class OpenSslHandler extends AEngineHandler implements IEngineHandler {
30
        public function __construct(
31
                protected IConfig $config,
32
                protected IAppConfig $appConfig,
33
                protected IAppDataFactory $appDataFactory,
34
                protected IDateTimeFormatter $dateTimeFormatter,
35
                protected ITempManager $tempManager,
36
                protected CertificatePolicyService $certificatePolicyService,
37
                protected IURLGenerator $urlGenerator,
38
                protected SerialNumberService $serialNumberService,
39
                protected CaIdentifierService $caIdentifierService,
40
        ) {
41
                parent::__construct(
64✔
42
                        $config,
64✔
43
                        $appConfig,
64✔
44
                        $appDataFactory,
64✔
45
                        $dateTimeFormatter,
64✔
46
                        $tempManager,
64✔
47
                        $certificatePolicyService,
64✔
48
                        $urlGenerator,
64✔
49
                        $caIdentifierService,
64✔
50
                );
64✔
51
        }
52

53
        #[\Override]
54
        public function generateRootCert(
55
                string $commonName,
56
                array $names = [],
57
        ): void {
58
                $privateKey = openssl_pkey_new([
14✔
59
                        'private_key_bits' => 2048,
14✔
60
                        'private_key_type' => OPENSSL_KEYTYPE_RSA,
14✔
61
                ]);
14✔
62

63
                $csr = openssl_csr_new($this->getCsrNames(), $privateKey, ['digest_alg' => 'sha256']);
14✔
64
                $options = $this->getRootCertOptions();
14✔
65

66
                $caDays = $this->getCaExpiryInDays();
14✔
67
                $serialNumber = $this->serialNumberService->generateUniqueSerial(
14✔
68
                        $commonName,
14✔
69
                        new \DateTime('+' . $caDays . ' days')
14✔
70
                );
14✔
71

72
                $x509 = openssl_csr_sign($csr, null, $privateKey, $days = $caDays, $options, $serialNumber);
14✔
73

74
                openssl_csr_export($csr, $csrout);
14✔
75
                openssl_x509_export($x509, $certout);
14✔
76
                openssl_pkey_export($privateKey, $pkeyout);
14✔
77

78
                $configPath = $this->getConfigPath();
14✔
79
                CertificateHelper::saveFile($configPath . '/ca.csr', $csrout);
14✔
80
                CertificateHelper::saveFile($configPath . '/ca.pem', $certout);
14✔
81
                CertificateHelper::saveFile($configPath . '/ca-key.pem', $pkeyout);
14✔
82
        }
83

84
        private function getRootCertOptions(): array {
85
                $configFile = $this->generateCaConfig();
14✔
86

87
                return [
14✔
88
                        'digest_alg' => 'sha256',
14✔
89
                        'config' => $configFile,
14✔
90
                        'x509_extensions' => 'v3_ca',
14✔
91
                ];
14✔
92
        }
93

94
        private function getLeafCertOptions(): array {
95
                $configFile = $this->generateLeafConfig();
7✔
96

97
                return [
7✔
98
                        'digest_alg' => 'sha256',
7✔
99
                        'config' => $configFile,
7✔
100
                        'x509_extensions' => 'v3_req',
7✔
101
                ];
7✔
102
        }
103

104
        #[\Override]
105
        public function generateCertificate(): string {
106
                $configPath = $this->getConfigPath();
8✔
107
                $rootCertificate = file_get_contents($configPath . DIRECTORY_SEPARATOR . 'ca.pem');
8✔
108
                $rootPrivateKey = file_get_contents($configPath . DIRECTORY_SEPARATOR . 'ca-key.pem');
8✔
109
                if (empty($rootCertificate) || empty($rootPrivateKey)) {
8✔
110
                        throw new LibresignException('Invalid root certificate');
×
111
                }
112

113
                $this->inheritRootSubjectFields($rootCertificate);
8✔
114

115
                $privateKey = openssl_pkey_new([
8✔
116
                        'private_key_bits' => 2048,
8✔
117
                        'private_key_type' => OPENSSL_KEYTYPE_RSA,
8✔
118
                ]);
8✔
119

120
                $csr = @openssl_csr_new($this->getCsrNames(), $privateKey, ['digest_alg' => 'sha256']);
8✔
121
                if ($csr === false) {
8✔
122
                        $message = openssl_error_string();
1✔
123
                        throw new LibresignException('OpenSSL error: ' . $message);
1✔
124
                }
125

126
                $serialNumber = $this->serialNumberService->generateUniqueSerial(
7✔
127
                        $this->getCommonName(),
7✔
128
                        new \DateTime('+' . $this->getLeafExpiryInDays() . ' days')
7✔
129
                );
7✔
130
                $options = $this->getLeafCertOptions();
7✔
131

132
                $x509 = openssl_csr_sign($csr, $rootCertificate, $rootPrivateKey, $this->getLeafExpiryInDays(), $options, $serialNumber);
7✔
133

134
                return parent::exportToPkcs12(
7✔
135
                        $x509,
7✔
136
                        $privateKey,
7✔
137
                        [
7✔
138
                                'friendly_name' => $this->getFriendlyName(),
7✔
139
                                'extracerts' => [
7✔
140
                                        $x509,
7✔
141
                                        $rootCertificate,
7✔
142
                                ],
7✔
143
                        ],
7✔
144
                );
7✔
145
        }
146

147
        private function inheritRootSubjectFields(string $rootCertificate): void {
148
                $parsedRoot = openssl_x509_parse($rootCertificate);
8✔
149
                if ($parsedRoot && isset($parsedRoot['subject']) && is_array($parsedRoot['subject'])) {
8✔
150
                        $map = [
8✔
151
                                'C' => 'country',
8✔
152
                                'ST' => 'state',
8✔
153
                                'L' => 'locality',
8✔
154
                                'O' => 'organization',
8✔
155
                                'OU' => 'organizationalUnit',
8✔
156
                        ];
8✔
157
                        foreach ($parsedRoot['subject'] as $k => $v) {
8✔
158
                                if (isset($map[$k])) {
8✔
159
                                        $setter = 'set' . ucfirst($map[$k]);
8✔
160
                                        if (method_exists($this, $setter)) {
8✔
161
                                                $this->$setter($v);
×
162
                                        }
163
                                }
164
                        }
165
                }
166
        }
167

168
        private function generateCaConfig(): string {
169
                $config = $this->buildCaCertificateConfig();
14✔
170
                $this->cleanupCaConfig($config);
14✔
171

172
                return $this->saveCaConfigFile($config);
14✔
173
        }
174

175
        private function generateLeafConfig(): string {
176
                $config = $this->buildLeafCertificateConfig();
7✔
177
                $this->cleanupLeafConfig($config);
7✔
178

179
                return $this->saveLeafConfigFile($config);
7✔
180
        }
181

182
        /**
183
         * More information about x509v3: https://www.openssl.org/docs/manmaster/man5/x509v3_config.html
184
         */
185
        private function buildCaCertificateConfig(): array {
186
                $config = [
14✔
187
                        'ca' => [
14✔
188
                                'default_ca' => 'CA_default'
14✔
189
                        ],
14✔
190
                        'CA_default' => [
14✔
191
                                'default_crl_days' => 7,
14✔
192
                                'default_md' => 'sha256',
14✔
193
                                'preserve' => 'no',
14✔
194
                                'policy' => 'policy_anything'
14✔
195
                        ],
14✔
196
                        'policy_anything' => [
14✔
197
                                'countryName' => 'optional',
14✔
198
                                'stateOrProvinceName' => 'optional',
14✔
199
                                'organizationName' => 'optional',
14✔
200
                                'organizationalUnitName' => 'optional',
14✔
201
                                'commonName' => 'supplied',
14✔
202
                                'emailAddress' => 'optional'
14✔
203
                        ],
14✔
204
                        'v3_ca' => [
14✔
205
                                'basicConstraints' => 'critical, CA:TRUE, pathlen:1',
14✔
206
                                'keyUsage' => 'critical, digitalSignature, keyCertSign',
14✔
207
                                'extendedKeyUsage' => 'clientAuth, emailProtection',
14✔
208
                                'subjectAltName' => $this->getSubjectAltNames(),
14✔
209
                                'authorityKeyIdentifier' => 'keyid',
14✔
210
                                'subjectKeyIdentifier' => 'hash',
14✔
211
                                'crlDistributionPoints' => 'URI:' . $this->getCrlDistributionUrl(),
14✔
212
                        ],
14✔
213
                        'crl_ext' => [
14✔
214
                                'issuerAltName' => 'issuer:copy',
14✔
215
                                'authorityKeyIdentifier' => 'keyid:always',
14✔
216
                                'subjectKeyIdentifier' => 'hash'
14✔
217
                        ]
14✔
218
                ];
14✔
219

220
                $this->addCaPolicies($config);
14✔
221

222
                return $config;
14✔
223
        }
224

225
        private function buildLeafCertificateConfig(): array {
226
                $config = [
7✔
227
                        'v3_req' => [
7✔
228
                                'basicConstraints' => 'CA:FALSE',
7✔
229
                                'keyUsage' => 'digitalSignature, keyEncipherment, nonRepudiation',
7✔
230
                                'extendedKeyUsage' => 'clientAuth, emailProtection',
7✔
231
                                'subjectAltName' => $this->getSubjectAltNames(),
7✔
232
                                'authorityKeyIdentifier' => 'keyid:always,issuer:always',
7✔
233
                                'subjectKeyIdentifier' => 'hash',
7✔
234
                                'crlDistributionPoints' => 'URI:' . $this->getCrlDistributionUrl(),
7✔
235
                        ],
7✔
236
                ];
7✔
237

238
                $this->addLeafPolicies($config);
7✔
239

240
                return $config;
7✔
241
        }
242

243
        private function addCaPolicies(array &$config): void {
244
                $oid = $this->certificatePolicyService->getOid();
14✔
245
                $cps = $this->certificatePolicyService->getCps();
14✔
246

247
                if (!$oid || !$cps) {
14✔
248
                        return;
14✔
249
                }
250

251
                $config['v3_ca']['certificatePolicies'] = '@policy_section';
×
252
                $config['policy_section'] = [
×
253
                        'policyIdentifier' => $oid,
×
254
                        'CPS.1' => $cps,
×
255
                ];
×
256
        }
257

258
        private function addLeafPolicies(array &$config): void {
259
                $oid = $this->certificatePolicyService->getOid();
7✔
260
                $cps = $this->certificatePolicyService->getCps();
7✔
261

262
                if (!$oid || !$cps) {
7✔
263
                        return;
7✔
264
                }
265

266
                $config['v3_req']['certificatePolicies'] = '@policy_section';
×
267
                $config['policy_section'] = [
×
268
                        'policyIdentifier' => $oid,
×
269
                        'CPS.1' => $cps,
×
270
                ];
×
271
        }
272

273
        private function cleanupCaConfig(array &$config): void {
274
                if (empty($config['v3_ca']['subjectAltName'])) {
14✔
275
                        unset($config['v3_ca']['subjectAltName']);
14✔
276
                }
277
        }
278

279
        private function cleanupLeafConfig(array &$config): void {
280
                if (empty($config['v3_req']['subjectAltName'])) {
7✔
281
                        unset($config['v3_req']['subjectAltName']);
4✔
282
                }
283
        }
284

285
        private function saveCaConfigFile(array $config): string {
286
                $iniContent = CertificateHelper::arrayToIni($config);
14✔
287
                $configFile = $this->getConfigPath() . '/openssl.cnf';
14✔
288
                CertificateHelper::saveFile($configFile, $iniContent);
14✔
289
                return $configFile;
14✔
290
        }
291

292
        private function saveLeafConfigFile(array $config): string {
293
                $iniContent = CertificateHelper::arrayToIni($config);
7✔
294
                $temporaryFile = $this->tempManager->getTemporaryFile('.cfg');
7✔
295
                if (!$temporaryFile) {
7✔
296
                        throw new LibresignException('Failure to create temporary file to OpenSSL .cfg file');
×
297
                }
298
                file_put_contents($temporaryFile, $iniContent);
7✔
299
                return $temporaryFile;
7✔
300
        }
301

302
        private function getSubjectAltNames(): string {
303
                $hosts = $this->getHosts();
14✔
304
                $altNames = [];
14✔
305
                foreach ($hosts as $host) {
14✔
306
                        if (filter_var($host, FILTER_VALIDATE_EMAIL)) {
4✔
307
                                $altNames[] = 'email:' . $host;
3✔
308
                        }
309
                }
310
                return implode(', ', $altNames);
14✔
311
        }
312

313
        /**
314
         * Convert to names as necessary to OpenSSL
315
         *
316
         * Read more here: https://www.php.net/manual/en/function.openssl-csr-new.php
317
         */
318
        private function getCsrNames(): array {
319
                $distinguishedNames = [];
14✔
320
                $names = parent::getNames();
14✔
321
                foreach ($names as $name => $value) {
14✔
322
                        if ($name === 'ST') {
3✔
323
                                $distinguishedNames['stateOrProvinceName'] = $value;
3✔
324
                                continue;
3✔
325
                        }
326
                        if ($name === 'UID') {
3✔
327
                                $distinguishedNames['UID'] = $value;
×
328
                                continue;
×
329
                        }
330

331
                        $longName = $this->translateToLong($name);
3✔
332
                        $longName = lcfirst($longName) . 'Name';
3✔
333

334
                        if (is_array($value)) {
3✔
335
                                if (!empty($value)) {
2✔
336
                                        $distinguishedNames[$longName] = implode(', ', $value);
2✔
337
                                }
338
                        } else {
339
                                $distinguishedNames[$longName] = $value;
3✔
340
                        }
341
                }
342
                if ($this->getCommonName()) {
14✔
343
                        $distinguishedNames['commonName'] = $this->getCommonName();
8✔
344
                }
345
                return $distinguishedNames;
14✔
346
        }
347

348
        #[\Override]
349
        public function isSetupOk(): bool {
350
                $configPath = $this->getConfigPath();
2✔
351
                if (empty($configPath)) {
2✔
NEW
352
                        return false;
×
353
                }
354
                $certificate = file_exists($configPath . DIRECTORY_SEPARATOR . 'ca.pem');
2✔
355
                $privateKey = file_exists($configPath . DIRECTORY_SEPARATOR . 'ca-key.pem');
2✔
356
                return $certificate && $privateKey;
2✔
357
        }
358

359
        #[\Override]
360
        protected function getConfigureCheckResourceName(): string {
361
                return 'openssl-configure';
1✔
362
        }
363

364
        #[\Override]
365
        protected function getCertificateRegenerationTip(): string {
366
                return 'Consider regenerating the root certificate with: occ libresign:configure:openssl --cn="Your CA Name"';
×
367
        }
368

369
        #[\Override]
370
        protected function getEngineSpecificChecks(): array {
371
                return [];
1✔
372
        }
373

374
        #[\Override]
375
        protected function getSetupSuccessMessage(): string {
376
                return 'Root certificate setup is working fine.';
×
377
        }
378

379
        #[\Override]
380
        protected function getSetupErrorMessage(): string {
381
                return 'OpenSSL (root certificate) not configured.';
1✔
382
        }
383

384
        #[\Override]
385
        protected function getSetupErrorTip(): string {
386
                return 'Run occ libresign:configure:openssl --help';
1✔
387
        }
388

389
        #[\Override]
390
        public function generateCrlDer(array $revokedCertificates): string {
391
                $configPath = $this->getConfigPath();
2✔
392
                $caCertPath = $configPath . DIRECTORY_SEPARATOR . 'ca.pem';
2✔
393
                $caKeyPath = $configPath . DIRECTORY_SEPARATOR . 'ca-key.pem';
2✔
394
                $crlDerPath = $configPath . DIRECTORY_SEPARATOR . 'crl.der';
2✔
395

396
                if (!file_exists($caCertPath) || !file_exists($caKeyPath)) {
2✔
397
                        throw new \RuntimeException('CA certificate or private key not found. Run: occ libresign:configure:openssl');
×
398
                }
399

400
                if ($this->isCrlUpToDate($crlDerPath, $revokedCertificates)) {
2✔
401
                        $content = file_get_contents($crlDerPath);
×
402
                        if ($content === false) {
×
403
                                throw new \RuntimeException('Failed to read existing CRL file');
×
404
                        }
405
                        return $content;
×
406
                }
407

408
                $crlConfigPath = $this->createCrlConfig($revokedCertificates);
2✔
409
                $crlPemPath = $configPath . DIRECTORY_SEPARATOR . 'crl.pem';
2✔
410

411
                try {
412
                        $command = sprintf(
2✔
413
                                'openssl ca -gencrl -out %s -config %s -cert %s -keyfile %s',
2✔
414
                                escapeshellarg($crlPemPath),
2✔
415
                                escapeshellarg($crlConfigPath),
2✔
416
                                escapeshellarg($caCertPath),
2✔
417
                                escapeshellarg($caKeyPath)
2✔
418
                        );
2✔
419

420
                        $output = [];
2✔
421
                        $returnCode = 0;
2✔
422
                        exec($command . ' 2>&1', $output, $returnCode);
2✔
423

424
                        if ($returnCode !== 0) {
2✔
425
                                throw new \RuntimeException('Failed to generate CRL: ' . implode("\n", $output));
×
426
                        }
427

428
                        $convertCommand = sprintf(
2✔
429
                                'openssl crl -in %s -outform DER -out %s',
2✔
430
                                escapeshellarg($crlPemPath),
2✔
431
                                escapeshellarg($crlDerPath)
2✔
432
                        );
2✔
433

434
                        exec($convertCommand . ' 2>&1', $output, $returnCode);
2✔
435

436
                        if ($returnCode !== 0) {
2✔
437
                                throw new \RuntimeException('Failed to convert CRL to DER format: ' . implode("\n", $output));
×
438
                        }
439

440
                        $derContent = file_get_contents($crlDerPath);
2✔
441
                        if ($derContent === false) {
2✔
442
                                throw new \RuntimeException('Failed to read generated CRL DER file');
×
443
                        }
444

445
                        return $derContent;
2✔
446
                } catch (\Exception $e) {
×
447
                        throw new \RuntimeException('Failed to generate CRL: ' . $e->getMessage(), 0, $e);
×
448
                }
449
        }
450

451
        private function isCrlUpToDate(string $crlDerPath, array $revokedCertificates): bool {
452
                if (!file_exists($crlDerPath)) {
2✔
453
                        return false;
2✔
454
                }
455

456
                $crlAge = time() - filemtime($crlDerPath);
×
457
                if ($crlAge > 86400) { // 24 hours
×
458
                        return false;
×
459
                }
460

461
                return true;
×
462
        }
463

464
        private function createCrlConfig(array $revokedCertificates): string {
465
                $configPath = $this->getConfigPath();
2✔
466
                $indexFile = $configPath . DIRECTORY_SEPARATOR . 'index.txt';
2✔
467
                $crlNumberFile = $configPath . DIRECTORY_SEPARATOR . 'crlnumber';
2✔
468
                $configFile = $configPath . DIRECTORY_SEPARATOR . 'crl.conf';
2✔
469

470
                $existingContent = file_exists($indexFile) ? file_get_contents($indexFile) : '';
2✔
471
                $existingSerials = [];
2✔
472

473
                if ($existingContent) {
2✔
474
                        foreach (explode("\n", trim($existingContent)) as $line) {
×
475
                                if (preg_match('/^R\t.*\t.*\t([A-F0-9]+)\t/', $line, $matches)) {
×
476
                                        $existingSerials[] = $matches[1];
×
477
                                }
478
                        }
479
                }
480

481
                $newContent = '';
2✔
482
                foreach ($revokedCertificates as $cert) {
2✔
483
                        $serialHex = strtoupper(dechex($cert->getSerialNumber()));
×
484

485
                        if (in_array($serialHex, $existingSerials)) {
×
486
                                continue;
×
487
                        }
488

489
                        $revokedAt = new \DateTime($cert->getRevokedAt()->format('Y-m-d H:i:s'));
×
490
                        $reasonCode = $cert->getReasonCode() ?? 0;
×
491
                        $newContent .= sprintf(
×
492
                                "R\t%s\t%s,%02d\t%s\tunknown\t/CN=%s\n",
×
493
                                $cert->getValidTo() ? $cert->getValidTo()->format('ymdHis\Z') : '501231235959Z',
×
494
                                $revokedAt->format('ymdHis\Z'),
×
495
                                $reasonCode,
×
496
                                $serialHex,
×
497
                                $cert->getOwner()
×
498
                        );
×
499
                }
500

501
                file_put_contents($indexFile, $existingContent . $newContent);
2✔
502

503
                if (!file_exists($crlNumberFile)) {
2✔
504
                        file_put_contents($crlNumberFile, "01\n");
2✔
505
                }
506

507
                $crlConfig = $this->buildCaCertificateConfig();
2✔
508

509
                $crlConfig['CA_default']['dir'] = dirname($indexFile);
2✔
510
                $crlConfig['CA_default']['database'] = $indexFile;
2✔
511
                $crlConfig['CA_default']['crlnumber'] = $crlNumberFile;
2✔
512

513
                $configContent = CertificateHelper::arrayToIni($crlConfig);
2✔
514
                file_put_contents($configFile, $configContent);
2✔
515

516
                return $configFile;
2✔
517
        }
518
}
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