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

LibreSign / libresign / 19124950457

06 Nov 2025 04:35AM UTC coverage: 39.311%. First build
19124950457

Pull #5757

github

web-flow
Merge b88a58dc1 into f56fcda6c
Pull Request #5757: feat: use crl by cert

54 of 116 new or added lines in 11 files covered. (46.55%)

4599 of 11699 relevant lines covered (39.31%)

3.08 hits per line

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

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

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

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

67
                $csr = openssl_csr_new($this->getCsrNames(), $privateKey, ['digest_alg' => 'sha256']);
14✔
68
                $options = $this->getRootCertOptions();
14✔
69

70
                $caDays = $this->getCaExpiryInDays();
14✔
71
                $serialNumberString = $this->serialNumberService->generateUniqueSerial(
14✔
72
                        $commonName,
14✔
73
                        $this->caIdentifierService->getInstanceId(),
14✔
74
                        new \DateTime('+' . $caDays . ' days'),
14✔
75
                        'openssl',
14✔
76
                );
14✔
77
                $serialNumber = (int)$serialNumberString;
14✔
78

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

81
                openssl_csr_export($csr, $csrout);
14✔
82
                openssl_x509_export($x509, $certout);
14✔
83
                openssl_pkey_export($privateKey, $pkeyout);
14✔
84

85
                $configPath = $this->getCurrentConfigPath();
14✔
86
                CertificateHelper::saveFile($configPath . '/ca.csr', $csrout);
14✔
87
                CertificateHelper::saveFile($configPath . '/ca.pem', $certout);
14✔
88
                CertificateHelper::saveFile($configPath . '/ca-key.pem', $pkeyout);
14✔
89
        }
90

91
        private function getRootCertOptions(): array {
92
                $configFile = $this->generateCaConfig();
14✔
93

94
                return [
14✔
95
                        'digest_alg' => 'sha256',
14✔
96
                        'config' => $configFile,
14✔
97
                        'x509_extensions' => 'v3_ca',
14✔
98
                ];
14✔
99
        }
100

101
        private function getLeafCertOptions(): array {
102
                $configFile = $this->generateLeafConfig();
7✔
103

104
                return [
7✔
105
                        'digest_alg' => 'sha256',
7✔
106
                        'config' => $configFile,
7✔
107
                        'x509_extensions' => 'v3_req',
7✔
108
                ];
7✔
109
        }
110

111
        #[\Override]
112
        public function generateCertificate(): string {
113
                $configPath = $this->getCurrentConfigPath();
8✔
114
                $rootCertificate = file_get_contents($configPath . DIRECTORY_SEPARATOR . 'ca.pem');
8✔
115
                $rootPrivateKey = file_get_contents($configPath . DIRECTORY_SEPARATOR . 'ca-key.pem');
8✔
116
                if (empty($rootCertificate) || empty($rootPrivateKey)) {
8✔
117
                        throw new LibresignException('Invalid root certificate');
×
118
                }
119

120
                $this->inheritRootSubjectFields($rootCertificate);
8✔
121

122
                $privateKey = openssl_pkey_new([
8✔
123
                        'private_key_bits' => 2048,
8✔
124
                        'private_key_type' => OPENSSL_KEYTYPE_RSA,
8✔
125
                ]);
8✔
126

127
                $csr = @openssl_csr_new($this->getCsrNames(), $privateKey, ['digest_alg' => 'sha256']);
8✔
128
                if ($csr === false) {
8✔
129
                        $message = openssl_error_string();
1✔
130
                        throw new LibresignException('OpenSSL error: ' . $message);
1✔
131
                }
132

133
                $serialNumberString = $this->serialNumberService->generateUniqueSerial(
7✔
134
                        $this->getCommonName(),
7✔
135
                        $this->caIdentifierService->getInstanceId(),
7✔
136
                        new \DateTime('+' . $this->getLeafExpiryInDays() . ' days'),
7✔
137
                        'openssl',
7✔
138
                );
7✔
139
                $serialNumber = (int)$serialNumberString;
7✔
140
                $options = $this->getLeafCertOptions();
7✔
141

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

144
                return parent::exportToPkcs12(
7✔
145
                        $x509,
7✔
146
                        $privateKey,
7✔
147
                        [
7✔
148
                                'friendly_name' => $this->getFriendlyName(),
7✔
149
                                'extracerts' => [
7✔
150
                                        $x509,
7✔
151
                                        $rootCertificate,
7✔
152
                                ],
7✔
153
                        ],
7✔
154
                );
7✔
155
        }
156

157
        private function inheritRootSubjectFields(string $rootCertificate): void {
158
                $parsedRoot = openssl_x509_parse($rootCertificate);
8✔
159
                if ($parsedRoot && isset($parsedRoot['subject']) && is_array($parsedRoot['subject'])) {
8✔
160
                        $map = [
8✔
161
                                'C' => 'country',
8✔
162
                                'ST' => 'state',
8✔
163
                                'L' => 'locality',
8✔
164
                                'O' => 'organization',
8✔
165
                                'OU' => 'organizationalUnit',
8✔
166
                        ];
8✔
167
                        foreach ($parsedRoot['subject'] as $k => $v) {
8✔
168
                                if (isset($map[$k])) {
8✔
169
                                        $setter = 'set' . ucfirst($map[$k]);
8✔
170
                                        if (method_exists($this, $setter)) {
8✔
171
                                                $this->$setter($v);
×
172
                                        }
173
                                }
174
                        }
175
                }
176
        }
177

178
        private function generateCaConfig(): string {
179
                $config = $this->buildCaCertificateConfig();
14✔
180
                $this->cleanupCaConfig($config);
14✔
181

182
                return $this->saveCaConfigFile($config);
14✔
183
        }
184

185
        private function generateLeafConfig(): string {
186
                $config = $this->buildLeafCertificateConfig();
7✔
187
                $this->cleanupLeafConfig($config);
7✔
188

189
                return $this->saveLeafConfigFile($config);
7✔
190
        }
191

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

230
                $this->addCaPolicies($config);
14✔
231

232
                return $config;
14✔
233
        }
234

235
        private function buildLeafCertificateConfig(): array {
236
                $config = [
7✔
237
                        'v3_req' => [
7✔
238
                                'basicConstraints' => 'CA:FALSE',
7✔
239
                                'keyUsage' => 'digitalSignature, keyEncipherment, nonRepudiation',
7✔
240
                                'extendedKeyUsage' => 'clientAuth, emailProtection',
7✔
241
                                'subjectAltName' => $this->getSubjectAltNames(),
7✔
242
                                'authorityKeyIdentifier' => 'keyid:always,issuer:always',
7✔
243
                                'subjectKeyIdentifier' => 'hash',
7✔
244
                                'crlDistributionPoints' => 'URI:' . $this->getCrlDistributionUrl(),
7✔
245
                        ],
7✔
246
                ];
7✔
247

248
                $this->addLeafPolicies($config);
7✔
249

250
                return $config;
7✔
251
        }
252

253
        private function addCaPolicies(array &$config): void {
254
                $oid = $this->certificatePolicyService->getOid();
14✔
255
                $cps = $this->certificatePolicyService->getCps();
14✔
256

257
                if (!$oid || !$cps) {
14✔
258
                        return;
14✔
259
                }
260

261
                $config['v3_ca']['certificatePolicies'] = '@policy_section';
×
262
                $config['policy_section'] = [
×
263
                        'policyIdentifier' => $oid,
×
264
                        'CPS.1' => $cps,
×
265
                ];
×
266
        }
267

268
        private function addLeafPolicies(array &$config): void {
269
                $oid = $this->certificatePolicyService->getOid();
7✔
270
                $cps = $this->certificatePolicyService->getCps();
7✔
271

272
                if (!$oid || !$cps) {
7✔
273
                        return;
7✔
274
                }
275

276
                $config['v3_req']['certificatePolicies'] = '@policy_section';
×
277
                $config['policy_section'] = [
×
278
                        'policyIdentifier' => $oid,
×
279
                        'CPS.1' => $cps,
×
280
                ];
×
281
        }
282

283
        private function cleanupCaConfig(array &$config): void {
284
                if (empty($config['v3_ca']['subjectAltName'])) {
14✔
285
                        unset($config['v3_ca']['subjectAltName']);
14✔
286
                }
287
        }
288

289
        private function cleanupLeafConfig(array &$config): void {
290
                if (empty($config['v3_req']['subjectAltName'])) {
7✔
291
                        unset($config['v3_req']['subjectAltName']);
4✔
292
                }
293
        }
294

295
        private function saveCaConfigFile(array $config): string {
296
                $iniContent = CertificateHelper::arrayToIni($config);
14✔
297
                $configFile = $this->getCurrentConfigPath() . '/openssl.cnf';
14✔
298
                CertificateHelper::saveFile($configFile, $iniContent);
14✔
299
                return $configFile;
14✔
300
        }
301

302
        private function saveLeafConfigFile(array $config): string {
303
                $iniContent = CertificateHelper::arrayToIni($config);
7✔
304
                $temporaryFile = $this->tempManager->getTemporaryFile('.cfg');
7✔
305
                if (!$temporaryFile) {
7✔
306
                        throw new LibresignException('Failure to create temporary file to OpenSSL .cfg file');
×
307
                }
308
                file_put_contents($temporaryFile, $iniContent);
7✔
309
                return $temporaryFile;
7✔
310
        }
311

312
        private function getSubjectAltNames(): string {
313
                $hosts = $this->getHosts();
14✔
314
                $altNames = [];
14✔
315
                foreach ($hosts as $host) {
14✔
316
                        if (filter_var($host, FILTER_VALIDATE_EMAIL)) {
4✔
317
                                $altNames[] = 'email:' . $host;
3✔
318
                        }
319
                }
320
                return implode(', ', $altNames);
14✔
321
        }
322

323
        /**
324
         * Convert to names as necessary to OpenSSL
325
         *
326
         * Read more here: https://www.php.net/manual/en/function.openssl-csr-new.php
327
         */
328
        private function getCsrNames(): array {
329
                $distinguishedNames = [];
14✔
330
                $names = parent::getNames();
14✔
331
                foreach ($names as $name => $value) {
14✔
332
                        if ($name === 'ST') {
3✔
333
                                $distinguishedNames['stateOrProvinceName'] = $value;
3✔
334
                                continue;
3✔
335
                        }
336
                        if ($name === 'UID') {
3✔
337
                                $distinguishedNames['UID'] = $value;
×
338
                                continue;
×
339
                        }
340

341
                        $longName = $this->translateToLong($name);
3✔
342
                        $longName = lcfirst($longName) . 'Name';
3✔
343

344
                        if (is_array($value)) {
3✔
345
                                if (!empty($value)) {
2✔
346
                                        $distinguishedNames[$longName] = implode(', ', $value);
2✔
347
                                }
348
                        } else {
349
                                $distinguishedNames[$longName] = $value;
3✔
350
                        }
351
                }
352
                if ($this->getCommonName()) {
14✔
353
                        $distinguishedNames['commonName'] = $this->getCommonName();
8✔
354
                }
355
                return $distinguishedNames;
14✔
356
        }
357

358
        #[\Override]
359
        public function isSetupOk(): bool {
360
                $configPath = $this->getCurrentConfigPath();
2✔
361
                if (empty($configPath)) {
2✔
362
                        return false;
×
363
                }
364
                $certificate = file_exists($configPath . DIRECTORY_SEPARATOR . 'ca.pem');
2✔
365
                $privateKey = file_exists($configPath . DIRECTORY_SEPARATOR . 'ca-key.pem');
2✔
366
                return $certificate && $privateKey;
2✔
367
        }
368

369
        #[\Override]
370
        protected function getConfigureCheckResourceName(): string {
371
                return 'openssl-configure';
1✔
372
        }
373

374
        #[\Override]
375
        protected function getCertificateRegenerationTip(): string {
376
                return 'Consider regenerating the root certificate with: occ libresign:configure:openssl --cn="Your CA Name"';
×
377
        }
378

379
        #[\Override]
380
        protected function getEngineSpecificChecks(): array {
381
                return [];
1✔
382
        }
383

384
        #[\Override]
385
        protected function getSetupSuccessMessage(): string {
386
                return 'Root certificate setup is working fine.';
×
387
        }
388

389
        #[\Override]
390
        protected function getSetupErrorMessage(): string {
391
                return 'OpenSSL (root certificate) not configured.';
1✔
392
        }
393

394
        #[\Override]
395
        protected function getSetupErrorTip(): string {
396
                return 'Run occ libresign:configure:openssl --help';
1✔
397
        }
398

399
        #[\Override]
400
        public function generateCrlDer(array $revokedCertificates, string $instanceId, int $generation): string {
NEW
401
                $configPath = $this->getConfigPathByParams($instanceId, $generation);
×
402
                $caCertPath = $configPath . DIRECTORY_SEPARATOR . 'ca.pem';
×
403
                $caKeyPath = $configPath . DIRECTORY_SEPARATOR . 'ca-key.pem';
×
404
                $crlDerPath = $configPath . DIRECTORY_SEPARATOR . 'crl.der';
×
405

406
                if (!file_exists($caCertPath) || !file_exists($caKeyPath)) {
×
407
                        throw new \RuntimeException('CA certificate or private key not found. Run: occ libresign:configure:openssl');
×
408
                }
409

410
                if ($this->isCrlUpToDate($crlDerPath, $revokedCertificates)) {
×
411
                        $content = file_get_contents($crlDerPath);
×
412
                        if ($content === false) {
×
413
                                throw new \RuntimeException('Failed to read existing CRL file');
×
414
                        }
415
                        return $content;
×
416
                }
417

418
                $crlConfigPath = $this->createCrlConfig($revokedCertificates);
×
419
                $crlPemPath = $configPath . DIRECTORY_SEPARATOR . 'crl.pem';
×
420

421
                try {
422
                        $command = sprintf(
×
423
                                'openssl ca -gencrl -out %s -config %s -cert %s -keyfile %s',
×
424
                                escapeshellarg($crlPemPath),
×
425
                                escapeshellarg($crlConfigPath),
×
426
                                escapeshellarg($caCertPath),
×
427
                                escapeshellarg($caKeyPath)
×
428
                        );
×
429

430
                        $output = [];
×
431
                        $returnCode = 0;
×
432
                        exec($command . ' 2>&1', $output, $returnCode);
×
433

434
                        if ($returnCode !== 0) {
×
435
                                throw new \RuntimeException('Failed to generate CRL: ' . implode("\n", $output));
×
436
                        }
437

438
                        $convertCommand = sprintf(
×
439
                                'openssl crl -in %s -outform DER -out %s',
×
440
                                escapeshellarg($crlPemPath),
×
441
                                escapeshellarg($crlDerPath)
×
442
                        );
×
443

444
                        exec($convertCommand . ' 2>&1', $output, $returnCode);
×
445

446
                        if ($returnCode !== 0) {
×
447
                                throw new \RuntimeException('Failed to convert CRL to DER format: ' . implode("\n", $output));
×
448
                        }
449

450
                        $derContent = file_get_contents($crlDerPath);
×
451
                        if ($derContent === false) {
×
452
                                throw new \RuntimeException('Failed to read generated CRL DER file');
×
453
                        }
454

455
                        return $derContent;
×
456
                } catch (\Exception $e) {
×
457
                        throw new \RuntimeException('Failed to generate CRL: ' . $e->getMessage(), 0, $e);
×
458
                }
459
        }
460

461
        private function isCrlUpToDate(string $crlDerPath, array $revokedCertificates): bool {
462
                if (!file_exists($crlDerPath)) {
×
463
                        return false;
×
464
                }
465

466
                $crlAge = time() - filemtime($crlDerPath);
×
467
                if ($crlAge > 86400) { // 24 hours
×
468
                        return false;
×
469
                }
470

471
                return true;
×
472
        }
473

474
        private function createCrlConfig(array $revokedCertificates): string {
NEW
475
                $configPath = $this->getCurrentConfigPath();
×
476
                $indexFile = $configPath . DIRECTORY_SEPARATOR . 'index.txt';
×
477
                $crlNumberFile = $configPath . DIRECTORY_SEPARATOR . 'crlnumber';
×
478
                $configFile = $configPath . DIRECTORY_SEPARATOR . 'crl.conf';
×
479

480
                $existingContent = file_exists($indexFile) ? file_get_contents($indexFile) : '';
×
481
                $existingSerials = [];
×
482

483
                if ($existingContent) {
×
484
                        foreach (explode("\n", trim($existingContent)) as $line) {
×
485
                                if (preg_match('/^R\t.*\t.*\t([A-F0-9]+)\t/', $line, $matches)) {
×
486
                                        $existingSerials[] = $matches[1];
×
487
                                }
488
                        }
489
                }
490

491
                $newContent = '';
×
492
                foreach ($revokedCertificates as $cert) {
×
493
                        $serialHex = strtoupper(dechex($cert->getSerialNumber()));
×
494

495
                        if (in_array($serialHex, $existingSerials)) {
×
496
                                continue;
×
497
                        }
498

499
                        $revokedAt = new \DateTime($cert->getRevokedAt()->format('Y-m-d H:i:s'));
×
500
                        $reasonCode = $cert->getReasonCode() ?? 0;
×
501
                        $newContent .= sprintf(
×
502
                                "R\t%s\t%s,%02d\t%s\tunknown\t/CN=%s\n",
×
503
                                $cert->getValidTo() ? $cert->getValidTo()->format('ymdHis\Z') : '501231235959Z',
×
504
                                $revokedAt->format('ymdHis\Z'),
×
505
                                $reasonCode,
×
506
                                $serialHex,
×
507
                                $cert->getOwner()
×
508
                        );
×
509
                }
510

511
                file_put_contents($indexFile, $existingContent . $newContent);
×
512

513
                if (!file_exists($crlNumberFile)) {
×
514
                        file_put_contents($crlNumberFile, "01\n");
×
515
                }
516

517
                $crlConfig = $this->buildCaCertificateConfig();
×
518

519
                $crlConfig['CA_default']['dir'] = dirname($indexFile);
×
520
                $crlConfig['CA_default']['database'] = $indexFile;
×
521
                $crlConfig['CA_default']['crlnumber'] = $crlNumberFile;
×
522

523
                $configContent = CertificateHelper::arrayToIni($crlConfig);
×
524
                file_put_contents($configFile, $configContent);
×
525

526
                return $configFile;
×
527
        }
528
}
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