Code Coverage
 
Lines
Functions and Methods
Classes and Traits
Total
90.94% covered (success)
90.94%
251 / 276
64.52% covered (warning)
64.52%
20 / 31
CRAP
0.00% covered (danger)
0.00%
0 / 1
OpenSslHandler
90.94% covered (success)
90.94%
251 / 276
64.52% covered (warning)
64.52%
20 / 31
69.24
0.00% covered (danger)
0.00%
0 / 1
 __construct
100.00% covered (success)
100.00%
13 / 13
100.00% covered (success)
100.00%
1 / 1
1
 generateRootCert
90.24% covered (success)
90.24%
37 / 41
0.00% covered (danger)
0.00%
0 / 1
8.06
 getRootCsrOptions
100.00% covered (success)
100.00%
5 / 5
100.00% covered (success)
100.00%
1 / 1
1
 getRootCertOptions
100.00% covered (success)
100.00%
5 / 5
100.00% covered (success)
100.00%
1 / 1
1
 getLeafCertOptions
100.00% covered (success)
100.00%
6 / 6
100.00% covered (success)
100.00%
1 / 1
1
 generateCertificate
93.48% covered (success)
93.48%
43 / 46
0.00% covered (danger)
0.00%
0 / 1
6.01
 inheritRootSubjectFields
92.86% covered (success)
92.86%
13 / 14
0.00% covered (danger)
0.00%
0 / 1
7.02
 createPrivateKey
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 createCsr
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 signCsr
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 runOpenSslOperation
100.00% covered (success)
100.00%
7 / 7
100.00% covered (success)
100.00%
1 / 1
1
 buildOpenSslException
85.71% covered (warning)
85.71%
6 / 7
0.00% covered (danger)
0.00%
0 / 1
3.03
 generateCaConfig
100.00% covered (success)
100.00%
3 / 3
100.00% covered (success)
100.00%
1 / 1
1
 generateLeafConfig
100.00% covered (success)
100.00%
3 / 3
100.00% covered (success)
100.00%
1 / 1
1
 buildCaCertificateConfig
100.00% covered (success)
100.00%
40 / 40
100.00% covered (success)
100.00%
1 / 1
1
 buildLeafCertificateConfig
100.00% covered (success)
100.00%
19 / 19
100.00% covered (success)
100.00%
1 / 1
1
 addCaPolicies
44.44% covered (danger)
44.44%
4 / 9
0.00% covered (danger)
0.00%
0 / 1
4.54
 addLeafPolicies
44.44% covered (danger)
44.44%
4 / 9
0.00% covered (danger)
0.00%
0 / 1
4.54
 cleanupCaConfig
100.00% covered (success)
100.00%
2 / 2
100.00% covered (success)
100.00%
1 / 1
2
 cleanupLeafConfig
100.00% covered (success)
100.00%
2 / 2
100.00% covered (success)
100.00%
1 / 1
2
 saveCaConfigFile
100.00% covered (success)
100.00%
4 / 4
100.00% covered (success)
100.00%
1 / 1
1
 saveLeafConfigFile
83.33% covered (warning)
83.33%
5 / 6
0.00% covered (danger)
0.00%
0 / 1
2.02
 getSubjectAltNames
100.00% covered (success)
100.00%
2 / 2
100.00% covered (success)
100.00%
1 / 1
1
 getCsrNames
88.89% covered (warning)
88.89%
16 / 18
0.00% covered (danger)
0.00%
0 / 1
7.07
 isSetupOk
83.33% covered (warning)
83.33%
5 / 6
0.00% covered (danger)
0.00%
0 / 1
3.04
 getConfigureCheckResourceName
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 getCertificateRegenerationTip
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 getEngineSpecificChecks
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 getSetupSuccessMessage
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 getSetupErrorMessage
0.00% covered (danger)
0.00%
0 / 1
0.00% covered (danger)
0.00%
0 / 1
2
 getSetupErrorTip
0.00% covered (danger)
0.00%
0 / 1
0.00% covered (danger)
0.00%
0 / 1
2
1<?php
2
3declare(strict_types=1);
4/**
5 * SPDX-FileCopyrightText: 2020-2024 LibreCode coop and contributors
6 * SPDX-License-Identifier: AGPL-3.0-or-later
7 */
8
9namespace OCA\Libresign\Handler\CertificateEngine;
10
11use OCA\Libresign\Db\CrlMapper;
12use OCA\Libresign\Enum\CertificateType;
13use OCA\Libresign\Exception\EmptyCertificateException;
14use OCA\Libresign\Exception\LibresignException;
15use OCA\Libresign\Service\CaIdentifierService;
16use OCA\Libresign\Service\CertificatePolicyService;
17use OCA\Libresign\Service\Crl\CrlRevocationChecker;
18use OCA\Libresign\Service\Policy\PolicyService;
19use OCA\Libresign\Service\SerialNumberService;
20use OCA\Libresign\Service\SubjectAlternativeNameService;
21use OCP\Files\AppData\IAppDataFactory;
22use OCP\IAppConfig;
23use OCP\IConfig;
24use OCP\IDateTimeFormatter;
25use OCP\ITempManager;
26use OCP\IURLGenerator;
27use Psr\Log\LoggerInterface;
28
29/**
30 * Class FileMapper
31 *
32 * @package OCA\Libresign\Handler
33 *
34 * @method CfsslHandler setClient(Client $client)
35 */
36class OpenSslHandler extends AEngineHandler implements IEngineHandler {
37    /** @var list<string> */
38    private array $lastOpenSslErrors = [];
39
40    public function __construct(
41        protected IConfig $config,
42        protected IAppConfig $appConfig,
43        protected IAppDataFactory $appDataFactory,
44        protected IDateTimeFormatter $dateTimeFormatter,
45        protected ITempManager $tempManager,
46        protected CertificatePolicyService $certificatePolicyService,
47        protected IURLGenerator $urlGenerator,
48        protected SerialNumberService $serialNumberService,
49        protected CaIdentifierService $caIdentifierService,
50        protected PolicyService $policyService,
51        protected LoggerInterface $logger,
52        protected CrlMapper $crlMapper,
53        protected SubjectAlternativeNameService $subjectAlternativeNameService,
54        CrlRevocationChecker $crlRevocationChecker,
55    ) {
56        parent::__construct(
57            $config,
58            $appConfig,
59            $appDataFactory,
60            $dateTimeFormatter,
61            $tempManager,
62            $certificatePolicyService,
63            $urlGenerator,
64            $caIdentifierService,
65            $policyService,
66            $logger,
67            $crlRevocationChecker,
68        );
69    }
70
71    #[\Override]
72    public function generateRootCert(
73        string $commonName,
74        array $names = [],
75    ): void {
76        if (empty($commonName)) {
77            throw new EmptyCertificateException('Common Name (CN) cannot be empty for root certificate');
78        }
79
80        $privateKey = $this->createPrivateKey([
81            'private_key_bits' => 2048,
82            'private_key_type' => OPENSSL_KEYTYPE_RSA,
83        ]);
84        if ($privateKey === false) {
85            throw $this->buildOpenSslException('Failed to generate OpenSSL private key for root certificate');
86        }
87
88        $configFile = $this->generateCaConfig();
89        $csr = $this->createCsr($this->getCsrNames(), $privateKey, $this->getRootCsrOptions($configFile));
90        if ($csr === false) {
91            throw $this->buildOpenSslException('Failed to generate OpenSSL CSR for root certificate');
92        }
93        $options = $this->getRootCertOptions($configFile);
94
95        $caDays = $this->getCaExpiryInDays();
96
97        $subject = parent::getNames();
98        $subject['CN'] = $commonName;
99        $issuer = $subject;
100
101        $serialNumberString = $this->serialNumberService->generateUniqueSerial(
102            $commonName,
103            $this->caIdentifierService->getInstanceId(),
104            $this->caIdentifierService->getCaIdParsed()['generation'],
105            new \DateTime('+' . $caDays . ' days'),
106            'openssl',
107            $issuer,
108            $subject,
109            CertificateType::ROOT->value,
110        );
111        $serialNumber = (int)$serialNumberString;
112
113        $x509 = $this->signCsr($csr, null, $privateKey, $caDays, $options, $serialNumber);
114        if ($x509 === false) {
115            throw $this->buildOpenSslException('Failed to sign OpenSSL root certificate');
116        }
117
118        if (!openssl_csr_export($csr, $csrout)) {
119            throw $this->buildOpenSslException('Failed to export OpenSSL root CSR');
120        }
121        if (!openssl_x509_export($x509, $certout)) {
122            throw $this->buildOpenSslException('Failed to export OpenSSL root certificate');
123        }
124        if (!openssl_pkey_export($privateKey, $pkeyout)) {
125            throw $this->buildOpenSslException('Failed to export OpenSSL private key');
126        }
127
128        $configPath = $this->getCurrentConfigPath();
129        CertificateHelper::saveFile($configPath . '/ca.csr', $csrout);
130        CertificateHelper::saveFile($configPath . '/ca.pem', $certout);
131        CertificateHelper::saveFile($configPath . '/ca-key.pem', $pkeyout);
132    }
133
134    private function getRootCsrOptions(string $configFile): array {
135        return [
136            'digest_alg' => 'sha256',
137            'config' => $configFile,
138            'config_section_name' => 'req',
139        ];
140    }
141
142    private function getRootCertOptions(string $configFile): array {
143
144        return [
145            'digest_alg' => 'sha256',
146            'config' => $configFile,
147            'x509_extensions' => 'v3_ca',
148        ];
149    }
150
151    private function getLeafCertOptions(): array {
152        $configFile = $this->generateLeafConfig();
153
154        return [
155            'digest_alg' => 'sha256',
156            'config' => $configFile,
157            'x509_extensions' => 'v3_req',
158        ];
159    }
160
161    #[\Override]
162    public function generateCertificate(): string {
163        $this->validateRootCertificate();
164
165        $configPath = $this->getCurrentConfigPath();
166        $rootCertificate = file_get_contents($configPath . DIRECTORY_SEPARATOR . 'ca.pem');
167        $rootPrivateKey = file_get_contents($configPath . DIRECTORY_SEPARATOR . 'ca-key.pem');
168        if (empty($rootCertificate) || empty($rootPrivateKey)) {
169            throw new LibresignException('Invalid root certificate');
170        }
171
172        $this->inheritRootSubjectFields($rootCertificate);
173
174        $privateKey = $this->createPrivateKey([
175            'private_key_bits' => 2048,
176            'private_key_type' => OPENSSL_KEYTYPE_RSA,
177        ]);
178        if ($privateKey === false) {
179            throw $this->buildOpenSslException('Failed to generate OpenSSL private key');
180        }
181
182        $csr = $this->createCsr($this->getCsrNames(), $privateKey, ['digest_alg' => 'sha256']);
183        if ($csr === false) {
184            throw $this->buildOpenSslException('Failed to generate OpenSSL CSR');
185        }
186
187        $parsedRoot = openssl_x509_parse($rootCertificate);
188        /** @var array<string, mixed> $issuer */
189        $issuer = $parsedRoot['subject'] ?? [];
190
191        $subject = parent::getNames();
192        $subject['CN'] = $this->getCommonName();
193
194        $serialNumberString = $this->serialNumberService->generateUniqueSerial(
195            $this->getCommonName(),
196            $this->caIdentifierService->getInstanceId(),
197            $this->caIdentifierService->getCaIdParsed()['generation'],
198            new \DateTime('+' . $this->getLeafExpiryInDays() . ' days'),
199            'openssl',
200            $issuer,
201            $subject,
202            CertificateType::LEAF->value,
203        );
204        $serialNumber = (int)$serialNumberString;
205        $options = $this->getLeafCertOptions();
206
207        $x509 = $this->signCsr($csr, $rootCertificate, $rootPrivateKey, $this->getLeafExpiryInDays(), $options, $serialNumber);
208        if ($x509 === false) {
209            throw $this->buildOpenSslException('Failed to sign OpenSSL certificate');
210        }
211
212        return parent::exportToPkcs12(
213            $x509,
214            $privateKey,
215            [
216                'friendly_name' => $this->getFriendlyName(),
217                'extracerts' => [
218                    $x509,
219                    $rootCertificate,
220                ],
221            ],
222        );
223    }
224
225    private function inheritRootSubjectFields(string $rootCertificate): void {
226        $parsedRoot = openssl_x509_parse($rootCertificate);
227        if ($parsedRoot && isset($parsedRoot['subject']) && is_array($parsedRoot['subject'])) {
228            $map = [
229                'C' => 'country',
230                'ST' => 'state',
231                'L' => 'locality',
232                'O' => 'organization',
233                'OU' => 'organizationalUnit',
234            ];
235            foreach ($parsedRoot['subject'] as $k => $v) {
236                if (isset($map[$k])) {
237                    $setter = 'set' . ucfirst($map[$k]);
238                    if (method_exists($this, $setter)) {
239                        $this->$setter($v);
240                    }
241                }
242            }
243        }
244    }
245
246    protected function createPrivateKey(array $options): mixed {
247        return $this->runOpenSslOperation(static fn () => openssl_pkey_new($options));
248    }
249
250    protected function createCsr(array $distinguishedNames, mixed $privateKey, array $options): mixed {
251        return $this->runOpenSslOperation(static fn () => openssl_csr_new($distinguishedNames, $privateKey, $options));
252    }
253
254    protected function signCsr(
255        mixed $csr,
256        mixed $caCertificate,
257        mixed $privateKey,
258        int $days,
259        array $options,
260        int $serialNumber,
261    ): mixed {
262        return $this->runOpenSslOperation(static fn () => openssl_csr_sign($csr, $caCertificate, $privateKey, $days, $options, $serialNumber));
263    }
264
265    private function runOpenSslOperation(callable $operation): mixed {
266        $this->lastOpenSslErrors = [];
267
268        set_error_handler(function (int $severity, string $message): bool {
269            $this->lastOpenSslErrors[] = $message;
270            return true;
271        });
272
273        try {
274            return $operation();
275        } finally {
276            restore_error_handler();
277        }
278    }
279
280    private function buildOpenSslException(string $message): LibresignException {
281        $errors = $this->lastOpenSslErrors;
282        while (($error = openssl_error_string()) !== false) {
283            $errors[] = $error;
284        }
285        $this->lastOpenSslErrors = [];
286
287        if (empty($errors)) {
288            return new LibresignException($message . ': unknown OpenSSL error');
289        }
290
291        return new LibresignException($message . ': ' . implode(' | ', $errors));
292    }
293
294    private function generateCaConfig(): string {
295        $config = $this->buildCaCertificateConfig();
296        $this->cleanupCaConfig($config);
297
298        return $this->saveCaConfigFile($config);
299    }
300
301    private function generateLeafConfig(): string {
302        $config = $this->buildLeafCertificateConfig();
303        $this->cleanupLeafConfig($config);
304
305        return $this->saveLeafConfigFile($config);
306    }
307
308    /**
309     * More information about x509v3: https://www.openssl.org/docs/manmaster/man5/x509v3_config.html
310     */
311    private function buildCaCertificateConfig(): array {
312        $config = [
313            'req' => [
314                'distinguished_name' => 'req_distinguished_name',
315                'x509_extensions' => 'v3_ca',
316                'prompt' => 'no',
317            ],
318            'req_distinguished_name' => [],
319            'ca' => [
320                'default_ca' => 'CA_default'
321            ],
322            'CA_default' => [
323                'default_crl_days' => 7,
324                'default_md' => 'sha256',
325                'preserve' => 'no',
326                'policy' => 'policy_anything'
327            ],
328            'policy_anything' => [
329                'countryName' => 'optional',
330                'stateOrProvinceName' => 'optional',
331                'organizationName' => 'optional',
332                'organizationalUnitName' => 'optional',
333                'commonName' => 'supplied',
334                'emailAddress' => 'optional'
335            ],
336            'v3_ca' => [
337                'basicConstraints' => 'critical, CA:TRUE, pathlen:1',
338                'keyUsage' => 'critical, digitalSignature, keyCertSign, cRLSign',
339                'subjectAltName' => $this->getSubjectAltNames(),
340                'authorityKeyIdentifier' => 'keyid',
341                'subjectKeyIdentifier' => 'hash',
342                'crlDistributionPoints' => 'URI:' . $this->getCrlDistributionUrl(),
343            ],
344            'crl_ext' => [
345                'issuerAltName' => 'issuer:copy',
346                'authorityKeyIdentifier' => 'keyid:always',
347                'subjectKeyIdentifier' => 'hash'
348            ]
349        ];
350
351        $this->addCaPolicies($config);
352
353        return $config;
354    }
355
356    private function buildLeafCertificateConfig(): array {
357        $config = [
358            'req' => [
359                'distinguished_name' => 'req_distinguished_name',
360                'req_extensions' => 'v3_req',
361                'prompt' => 'no',
362            ],
363            'req_distinguished_name' => [],
364            'v3_req' => [
365                'basicConstraints' => 'CA:FALSE',
366                'keyUsage' => 'digitalSignature, keyEncipherment, nonRepudiation',
367                'extendedKeyUsage' => 'clientAuth, emailProtection',
368                'subjectAltName' => $this->getSubjectAltNames(),
369                'authorityKeyIdentifier' => 'keyid:always,issuer:always',
370                'subjectKeyIdentifier' => 'hash',
371                'crlDistributionPoints' => 'URI:' . $this->getCrlDistributionUrl(),
372            ],
373        ];
374
375        $this->addLeafPolicies($config);
376
377        return $config;
378    }
379
380    private function addCaPolicies(array &$config): void {
381        $oid = $this->certificatePolicyService->getOid();
382        $cps = $this->certificatePolicyService->getCps();
383
384        if (!$oid || !$cps) {
385            return;
386        }
387
388        $config['v3_ca']['certificatePolicies'] = '@policy_section';
389        $config['policy_section'] = [
390            'policyIdentifier' => $oid,
391            'CPS.1' => $cps,
392        ];
393    }
394
395    private function addLeafPolicies(array &$config): void {
396        $oid = $this->certificatePolicyService->getOid();
397        $cps = $this->certificatePolicyService->getCps();
398
399        if (!$oid || !$cps) {
400            return;
401        }
402
403        $config['v3_req']['certificatePolicies'] = '@policy_section';
404        $config['policy_section'] = [
405            'policyIdentifier' => $oid,
406            'CPS.1' => $cps,
407        ];
408    }
409
410    private function cleanupCaConfig(array &$config): void {
411        if (empty($config['v3_ca']['subjectAltName'])) {
412            unset($config['v3_ca']['subjectAltName']);
413        }
414    }
415
416    private function cleanupLeafConfig(array &$config): void {
417        if (empty($config['v3_req']['subjectAltName'])) {
418            unset($config['v3_req']['subjectAltName']);
419        }
420    }
421
422    private function saveCaConfigFile(array $config): string {
423        $iniContent = CertificateHelper::arrayToIni($config);
424        $configFile = $this->getCurrentConfigPath() . '/openssl.cnf';
425        CertificateHelper::saveFile($configFile, $iniContent);
426        return $configFile;
427    }
428
429    private function saveLeafConfigFile(array $config): string {
430        $iniContent = CertificateHelper::arrayToIni($config);
431        $temporaryFile = $this->tempManager->getTemporaryFile('.cfg');
432        if (!$temporaryFile) {
433            throw new LibresignException('Failure to create temporary file to OpenSSL .cfg file');
434        }
435        file_put_contents($temporaryFile, $iniContent);
436        return $temporaryFile;
437    }
438
439    private function getSubjectAltNames(): string {
440        $hosts = $this->getHosts();
441        return $this->subjectAlternativeNameService->buildForHosts($hosts);
442    }
443
444    /**
445     * Convert to names as necessary to OpenSSL
446     *
447     * Read more here: https://www.php.net/manual/en/function.openssl-csr-new.php
448     */
449    private function getCsrNames(): array {
450        $distinguishedNames = [];
451        $names = parent::getNames();
452        foreach ($names as $name => $value) {
453            if ($name === 'ST') {
454                $distinguishedNames['stateOrProvinceName'] = $value;
455                continue;
456            }
457            if ($name === 'UID') {
458                $distinguishedNames['UID'] = $value;
459                continue;
460            }
461
462            $longName = $this->translateToLong($name);
463            $longName = lcfirst($longName) . 'Name';
464
465            if (is_array($value)) {
466                if (!empty($value)) {
467                    $distinguishedNames[$longName] = implode(', ', $value);
468                }
469            } else {
470                $distinguishedNames[$longName] = $value;
471            }
472        }
473        if ($this->getCommonName()) {
474            $distinguishedNames['commonName'] = $this->getCommonName();
475        }
476        return $distinguishedNames;
477    }
478
479    #[\Override]
480    public function isSetupOk(): bool {
481        $configPath = $this->getCurrentConfigPath();
482        if (empty($configPath)) {
483            return false;
484        }
485        $certificate = file_exists($configPath . DIRECTORY_SEPARATOR . 'ca.pem');
486        $privateKey = file_exists($configPath . DIRECTORY_SEPARATOR . 'ca-key.pem');
487        return $certificate && $privateKey;
488    }
489
490    #[\Override]
491    protected function getConfigureCheckResourceName(): string {
492        return 'openssl-configure';
493    }
494
495    #[\Override]
496    protected function getCertificateRegenerationTip(): string {
497        return 'Consider regenerating the root certificate with: occ libresign:configure:openssl --cn="Your CA Name"';
498    }
499
500    #[\Override]
501    protected function getEngineSpecificChecks(): array {
502        return [];
503    }
504
505    #[\Override]
506    protected function getSetupSuccessMessage(): string {
507        return 'Root certificate setup is working fine.';
508    }
509
510    #[\Override]
511    protected function getSetupErrorMessage(): string {
512        return 'OpenSSL (root certificate) not configured.';
513    }
514
515    #[\Override]
516    protected function getSetupErrorTip(): string {
517        return 'Run occ libresign:configure:openssl --help';
518    }
519}