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

LibreSign / libresign / 24935486331

25 Apr 2026 04:34PM UTC coverage: 56.515%. First build
24935486331

Pull #7605

github

web-flow
Merge cc02e9065 into 7922186b4
Pull Request #7605: fix: allow signing for legacy certificates missing CRL metadata (fixes #7597)

35 of 41 new or added lines in 1 file covered. (85.37%)

10591 of 18740 relevant lines covered (56.52%)

6.85 hits per line

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

68.97
/lib/Handler/CertificateEngine/AEngineHandler.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\AppInfo\Application;
12
use OCA\Libresign\Enum\CrlValidationStatus;
13
use OCA\Libresign\Exception\EmptyCertificateException;
14
use OCA\Libresign\Exception\InvalidPasswordException;
15
use OCA\Libresign\Exception\LibresignException;
16
use OCA\Libresign\Helper\ConfigureCheckHelper;
17
use OCA\Libresign\Helper\MagicGetterSetterTrait;
18
use OCA\Libresign\Service\CaIdentifierService;
19
use OCA\Libresign\Service\CertificatePolicyService;
20
use OCA\Libresign\Service\Crl\CrlRevocationChecker;
21
use OCP\Files\AppData\IAppDataFactory;
22
use OCP\Files\IAppData;
23
use OCP\Files\SimpleFS\ISimpleFolder;
24
use OCP\IAppConfig;
25
use OCP\IConfig;
26
use OCP\IDateTimeFormatter;
27
use OCP\ITempManager;
28
use OCP\IURLGenerator;
29
use OpenSSLAsymmetricKey;
30
use OpenSSLCertificate;
31
use Psr\Log\LoggerInterface;
32
use ReflectionClass;
33

34
/**
35
 * @method IEngineHandler setPassword(string $password)
36
 * @method string getPassword()
37
 * @method IEngineHandler setCommonName(string $commonName)
38
 * @method string getCommonName()
39
 * @method IEngineHandler setHosts(array $hosts)
40
 * @method array getHosts()
41
 * @method IEngineHandler setFriendlyName(string $friendlyName)
42
 * @method string getFriendlyName()
43
 * @method IEngineHandler setCountry(string $country)
44
 * @method string getCountry()
45
 * @method IEngineHandler setState(string $state)
46
 * @method string getState()
47
 * @method IEngineHandler setLocality(string $locality)
48
 * @method string getLocality()
49
 * @method IEngineHandler setOrganization(string $organization)
50
 * @method string getOrganization()
51
 * @method IEngineHandler setOrganizationalUnit(array $organizationalUnit)
52
 * @method array getOrganizationalUnit()
53
 * @method IEngineHandler setUID(string $UID)
54
 * @method string getName()
55
 */
56
abstract class AEngineHandler implements IEngineHandler {
57
        use MagicGetterSetterTrait;
58
        use OrderCertificatesTrait;
59

60
        protected string $commonName = '';
61
        protected array $hosts = [];
62
        protected string $friendlyName = '';
63
        protected string $country = '';
64
        protected string $state = '';
65
        protected string $locality = '';
66
        protected string $organization = '';
67
        protected array $organizationalUnit = [];
68
        protected string $UID = '';
69
        private ?int $leafExpiryOverrideInDays = null;
70
        protected string $password = '';
71
        protected string $configPath = '';
72
        protected string $engine = '';
73
        protected string $certificate = '';
74
        protected string $currentCaId = '';
75
        protected IAppData $appData;
76

77
        public function __construct(
78
                protected IConfig $config,
79
                protected IAppConfig $appConfig,
80
                protected IAppDataFactory $appDataFactory,
81
                protected IDateTimeFormatter $dateTimeFormatter,
82
                protected ITempManager $tempManager,
83
                protected CertificatePolicyService $certificatePolicyService,
84
                protected IURLGenerator $urlGenerator,
85
                protected CaIdentifierService $caIdentifierService,
86
                protected LoggerInterface $logger,
87
                private CrlRevocationChecker $crlRevocationChecker,
88
        ) {
89
                $this->appData = $appDataFactory->get('libresign');
156✔
90
        }
91

92
        protected function exportToPkcs12(
93
                OpenSSLCertificate|string $certificate,
94
                OpenSSLAsymmetricKey|OpenSSLCertificate|string $privateKey,
95
                array $options = [],
96
        ): string {
97
                if (empty($certificate) || empty($privateKey)) {
8✔
98
                        throw new EmptyCertificateException();
×
99
                }
100
                $certContent = null;
8✔
101
                try {
102
                        openssl_pkcs12_export(
8✔
103
                                $certificate,
8✔
104
                                $certContent,
8✔
105
                                $privateKey,
8✔
106
                                $this->getPassword(),
8✔
107
                                $options,
8✔
108
                        );
8✔
109
                        if (!$certContent) {
8✔
110
                                throw new \Exception();
8✔
111
                        }
112
                } catch (\Throwable) {
×
113
                        throw new LibresignException('Error while creating certificate file', 500);
×
114
                }
115

116
                return $certContent;
8✔
117
        }
118

119
        #[\Override]
120
        public function updatePassword(string $certificate, string $currentPrivateKey, string $newPrivateKey): string {
121
                if (empty($certificate) || empty($currentPrivateKey) || empty($newPrivateKey)) {
×
122
                        throw new EmptyCertificateException();
×
123
                }
124
                $certContent = $this->opensslPkcs12Read($certificate, $currentPrivateKey);
×
125
                $this->setPassword($newPrivateKey);
×
126
                $certContent = self::exportToPkcs12($certContent['cert'], $certContent['pkey']);
×
127
                return $certContent;
×
128
        }
129

130
        #[\Override]
131
        public function readCertificate(string $certificate, string $privateKey): array {
132
                if (empty($certificate) || empty($privateKey)) {
10✔
133
                        throw new EmptyCertificateException();
1✔
134
                }
135
                $certContent = $this->opensslPkcs12Read($certificate, $privateKey);
9✔
136

137
                $return = $this->parseX509($certContent['cert']);
7✔
138
                if (isset($certContent['extracerts'])) {
7✔
139
                        foreach ($certContent['extracerts'] as $extraCert) {
7✔
140
                                $return['extracerts'][] = $this->parseX509($extraCert);
7✔
141
                        }
142
                        $return['extracerts'] = $this->orderCertificates($return['extracerts']);
7✔
143
                }
144
                return $return;
7✔
145
        }
146

147
        public function getCaId(): string {
148
                $caId = $this->caIdentifierService->getCaId();
68✔
149
                if (empty($caId)) {
68✔
150
                        $this->appConfig->clearCache(true);
4✔
151
                        $caId = $this->caIdentifierService->getCaId() ?: $this->caIdentifierService->generateCaId($this->getName());
4✔
152
                }
153
                return $caId;
68✔
154
        }
155

156
        #[\Override]
157
        public function parseCertificate(string $certificate): array {
158
                return $this->parseX509($certificate);
3✔
159
        }
160

161
        private function parseX509(string $x509): array {
162
                $parsed = openssl_x509_parse(openssl_x509_read($x509));
10✔
163

164
                $return = self::convertArrayToUtf8($parsed);
10✔
165

166
                foreach (['subject', 'issuer'] as $actor) {
10✔
167
                        foreach ($return[$actor] as $part => $value) {
10✔
168
                                if (is_string($value) && str_contains($value, ', ')) {
10✔
169
                                        $return[$actor][$part] = explode(', ', $value);
3✔
170
                                } else {
171
                                        $return[$actor][$part] = $value;
10✔
172
                                }
173
                        }
174
                }
175

176
                $return['valid_from'] = $this->dateTimeFormatter->formatDateTime($parsed['validFrom_time_t']);
10✔
177
                $return['valid_to'] = $this->dateTimeFormatter->formatDateTime($parsed['validTo_time_t']);
10✔
178

179
                $this->addCrlValidationInfo($return, $x509);
10✔
180

181
                return $return;
10✔
182
        }
183

184
        private function addCrlValidationInfo(array &$certData, string $certPem): void {
185
                $extensions = $certData['extensions'] ?? [];
10✔
186
                if (is_array($extensions)) {
10✔
187
                        ['hasExtension' => $hasCrlExtension, 'urls' => $extractedUrls] = $this->extractCrlUrlsFromExtensions($extensions);
10✔
188
                        if ($hasCrlExtension) {
10✔
189
                                $certData['crl_urls'] = $extractedUrls;
9✔
190
                                if (empty($extractedUrls)) {
9✔
NEW
191
                                        $certData['crl_validation'] = CrlValidationStatus::NO_URLS;
×
NEW
192
                                        return;
×
193
                                }
194

195
                                $crlDetails = $this->crlRevocationChecker->validate($extractedUrls, $certPem);
9✔
196
                                $certData['crl_validation'] = $crlDetails['status'];
9✔
197
                                if (!empty($crlDetails['revoked_at'])) {
9✔
NEW
198
                                        $certData['crl_revoked_at'] = $crlDetails['revoked_at'];
×
199
                                }
200
                                return;
9✔
201
                        }
202
                }
203

204
                        $externalValidationEnabled = $this->appConfig->getValueBool(Application::APP_ID, 'crl_external_validation_enabled', true);
2✔
205
                        $certData['crl_validation'] = $externalValidationEnabled
2✔
206
                                ? CrlValidationStatus::MISSING
2✔
207
                                : CrlValidationStatus::DISABLED;
×
208
                        $certData['crl_urls'] = [];
2✔
209
        }
210

211
        /**
212
         * @return array{hasExtension: bool, urls: array<int, string>}
213
         */
214
        private function extractCrlUrlsFromExtensions(array $extensions): array {
215
                $values = [];
13✔
216
                $acceptedCrlExtensionNames = [
13✔
217
                        'crldistributionpoints',
13✔
218
                        'x509v3 crl distribution points',
13✔
219
                        '2.5.29.31',
13✔
220
                ];
13✔
221
                foreach ($extensions as $extensionName => $extensionValue) {
13✔
222
                        if (!is_string($extensionName)) {
13✔
NEW
223
                                continue;
×
224
                        }
225

226
                        $normalizedName = strtolower(trim($extensionName));
13✔
227
                        $isCrlDistributionPoints = in_array($normalizedName, $acceptedCrlExtensionNames, true);
13✔
228

229
                        if (!$isCrlDistributionPoints) {
13✔
230
                                continue;
11✔
231
                        }
232

233
                        if (is_string($extensionValue)) {
11✔
234
                                $values[] = $extensionValue;
11✔
NEW
235
                        } elseif (is_array($extensionValue)) {
×
NEW
236
                                $values[] = implode("\n", array_filter($extensionValue, 'is_string'));
×
237
                        }
238
                }
239

240
                if (empty($values)) {
13✔
241
                        return ['hasExtension' => false, 'urls' => []];
3✔
242
                }
243

244
                $urls = [];
11✔
245
                foreach ($values as $value) {
11✔
246
                        preg_match_all('/URI\s*:\s*([^\s,\n]+)/i', $value, $matches);
11✔
247
                        if (!empty($matches[1])) {
11✔
248
                                $urls = [...$urls, ...$matches[1]];
11✔
249
                        }
250
                }
251

252
                return [
11✔
253
                        'hasExtension' => true,
11✔
254
                        'urls' => array_values(array_unique($urls)),
11✔
255
                ];
11✔
256
        }
257

258
        private static function convertArrayToUtf8($array) {
259
                foreach ($array as $key => $value) {
10✔
260
                        if (is_array($value)) {
10✔
261
                                $array[$key] = self::convertArrayToUtf8($value);
10✔
262
                        } elseif (is_string($value)) {
10✔
263
                                $array[$key] = mb_convert_encoding($value, 'UTF-8', 'UTF-8');
10✔
264
                        }
265
                }
266
                return $array;
10✔
267
        }
268

269
        public function opensslPkcs12Read(string &$certificate, string $privateKey): array {
270
                openssl_pkcs12_read($certificate, $certContent, $privateKey);
9✔
271
                if (!empty($certContent)) {
9✔
272
                        return $certContent;
7✔
273
                }
274
                /**
275
                 * Reference:
276
                 *
277
                 * https://github.com/php/php-src/issues/12128
278
                 * https://www.php.net/manual/en/function.openssl-pkcs12-read.php#128992
279
                 */
280
                $msg = openssl_error_string();
2✔
281
                if ($msg === 'error:0308010C:digital envelope routines::unsupported') {
2✔
282
                        $tempPassword = $this->tempManager->getTemporaryFile();
×
283
                        $tempEncriptedOriginal = $this->tempManager->getTemporaryFile();
×
284
                        $tempEncriptedRepacked = $this->tempManager->getTemporaryFile();
×
285
                        $tempDecrypted = $this->tempManager->getTemporaryFile();
×
286
                        file_put_contents($tempPassword, $privateKey);
×
287
                        file_put_contents($tempEncriptedOriginal, $certificate);
×
288
                        shell_exec(<<<REPACK_COMMAND
×
289
                                cat $tempPassword | openssl pkcs12 -legacy -in $tempEncriptedOriginal -nodes -out $tempDecrypted -passin stdin &&
×
290
                                cat $tempPassword | openssl pkcs12 -in $tempDecrypted -export -out $tempEncriptedRepacked -passout stdin
×
291
                                REPACK_COMMAND
×
292
                        );
×
293
                        $certificateRepacked = file_get_contents($tempEncriptedRepacked);
×
294
                        openssl_pkcs12_read($certificateRepacked, $certContent, $privateKey);
×
295
                        if (!empty($certContent)) {
×
296
                                $certificate = $certificateRepacked;
×
297
                                return $certContent;
×
298
                        }
299
                }
300
                throw new InvalidPasswordException();
2✔
301
        }
302

303
        /**
304
         * @param (int|string) $name
305
         *
306
         * @psalm-param array-key $name
307
         */
308
        public function translateToLong($name): string {
309
                return match ($name) {
3✔
310
                        'CN' => 'CommonName',
×
311
                        'C' => 'Country',
3✔
312
                        'ST' => 'State',
×
313
                        'L' => 'Locality',
×
314
                        'O' => 'Organization',
3✔
315
                        'OU' => 'OrganizationalUnit',
2✔
316
                        'UID' => 'UserIdentifier',
×
317
                        default => '',
3✔
318
                };
3✔
319
        }
320

321
        #[\Override]
322
        public function setEngine(string $engine): void {
323
                $this->appConfig->setValueString(Application::APP_ID, 'certificate_engine', $engine);
16✔
324
                $this->engine = $engine;
16✔
325
                $this->configureIdentifyMethodsForEngine($engine);
16✔
326
        }
327

328
        /**
329
         * Configure identification methods based on the certificate engine.
330
         *
331
         * When the engine is set to 'none', only the 'account' identification method
332
         * is allowed. This is because:
333
         * - The 'none' engine doesn't generate digital certificates
334
         * - Without certificates, only basic password authentication is viable
335
         * - The 'account' method ensures users authenticate with their Nextcloud credentials
336
         *
337
         * For other engines (openssl, cfssl, java), the identification methods remain
338
         * unchanged to preserve existing configurations.
339
         *
340
         * @param string $engine The certificate engine name (i.e. 'none', 'openssl', 'cfssl')
341
         */
342
        private function configureIdentifyMethodsForEngine(string $engine): void {
343
                if ($engine !== 'none') {
16✔
344
                        return;
10✔
345
                }
346

347
                $config = [[
6✔
348
                        'name' => 'account',
6✔
349
                        'enabled' => true,
6✔
350
                        'mandatory' => true,
6✔
351
                ]];
6✔
352
                $this->appConfig->setValueArray(Application::APP_ID, 'identify_methods', $config);
6✔
353
        }
354

355
        #[\Override]
356
        public function getEngine(): string {
357
                if ($this->engine) {
3✔
358
                        return $this->engine;
3✔
359
                }
360
                $this->engine = $this->appConfig->getValueString(Application::APP_ID, 'certificate_engine', 'openssl');
×
361
                return $this->engine;
×
362
        }
363

364
        #[\Override]
365
        public function populateInstance(array $rootCert): IEngineHandler {
366
                if (empty($rootCert)) {
24✔
367
                        $rootCert = $this->appConfig->getValueArray(Application::APP_ID, 'rootCert');
24✔
368
                }
369
                if (!$rootCert) {
24✔
370
                        return $this;
24✔
371
                }
372
                if (!empty($rootCert['names'])) {
×
373
                        foreach ($rootCert['names'] as $id => $customName) {
×
374
                                $longCustomName = $this->translateToLong($id);
×
375
                                // Prevent to save a property that don't exists
376
                                if (!property_exists($this, lcfirst($longCustomName))) {
×
377
                                        continue;
×
378
                                }
379
                                $this->{'set' . ucfirst($longCustomName)}($customName['value']);
×
380
                        }
381
                }
382
                if (!$this->getCommonName()) {
×
383
                        $this->setCommonName($rootCert['commonName']);
×
384
                }
385
                return $this;
×
386
        }
387

388
        #[\Override]
389
        public function getCurrentConfigPath(): string {
390
                if ($this->configPath) {
78✔
391
                        return $this->configPath;
72✔
392
                }
393

394
                $customConfigPath = $this->appConfig->getValueString(Application::APP_ID, 'config_path');
68✔
395
                if ($customConfigPath && is_dir($customConfigPath)) {
68✔
396
                        $this->configPath = $customConfigPath;
10✔
397
                        return $this->configPath;
10✔
398
                }
399

400
                $this->configPath = $this->initializePkiConfigPath();
68✔
401
                if (!empty($this->configPath)) {
68✔
402
                        $this->appConfig->setValueString(Application::APP_ID, 'config_path', $this->configPath);
68✔
403
                }
404
                return $this->configPath;
68✔
405
        }
406

407
        #[\Override]
408
        public function getConfigPathByParams(string $instanceId, int $generation): string {
409
                $engineName = $this->getName();
20✔
410

411
                $pkiDirName = $this->caIdentifierService->generatePkiDirectoryNameFromParams($instanceId, $generation, $engineName);
20✔
412
                $dataDir = $this->config->getSystemValue('datadirectory', \OC::$SERVERROOT . '/data/');
20✔
413
                $systemInstanceId = $this->config->getSystemValue('instanceid');
20✔
414
                $pkiPath = $dataDir . '/appdata_' . $systemInstanceId . '/libresign/' . $pkiDirName;
20✔
415

416
                if (!is_dir($pkiPath)) {
20✔
417
                        throw new \RuntimeException("Config path does not exist for instanceId: {$instanceId}, generation: {$generation}");
2✔
418
                }
419

420
                return $pkiPath;
18✔
421
        }
422

423
        private function initializePkiConfigPath(): string {
424
                $caId = $this->getCaId();
68✔
425
                if (empty($caId)) {
68✔
426
                        return '';
×
427
                }
428
                $pkiDirName = $this->caIdentifierService->generatePkiDirectoryName($caId);
68✔
429
                $dataDir = $this->config->getSystemValue('datadirectory', \OC::$SERVERROOT . '/data/');
68✔
430
                $systemInstanceId = $this->config->getSystemValue('instanceid');
68✔
431
                $pkiPath = $dataDir . '/appdata_' . $systemInstanceId . '/libresign/' . $pkiDirName;
68✔
432

433
                if (!is_dir($pkiPath)) {
68✔
434
                        $this->createDirectoryWithCorrectOwnership($pkiPath);
68✔
435
                }
436

437
                return $pkiPath;
68✔
438
        }
439

440
        private function createDirectoryWithCorrectOwnership(string $path): void {
441
                $ownerInfo = $this->getFilesOwnerInfo();
68✔
442
                $fullCommand = 'mkdir -p ' . escapeshellarg($path);
68✔
443

444
                if (posix_getuid() !== $ownerInfo['uid']) {
68✔
445
                        $fullCommand = 'runuser -u ' . $ownerInfo['name'] . ' -- ' . $fullCommand;
×
446
                }
447

448
                exec($fullCommand);
68✔
449
        }
450

451
        private function getFilesOwnerInfo(): array {
452
                $currentFile = realpath(__DIR__);
68✔
453
                $owner = fileowner($currentFile);
68✔
454
                if ($owner === false) {
68✔
455
                        throw new \RuntimeException('Unable to get file information');
×
456
                }
457
                $ownerInfo = posix_getpwuid($owner);
68✔
458
                if ($ownerInfo === false) {
68✔
459
                        throw new \RuntimeException('Unable to get file owner information');
×
460
                }
461

462
                return $ownerInfo;
68✔
463
        }
464

465
        /**
466
         * @todo check a best solution to don't use reflection
467
         */
468
        private function getInternalPathOfFolder(ISimpleFolder $node): string {
469
                $reflection = new \ReflectionClass($node);
×
470
                $reflectionProperty = $reflection->getProperty('folder');
×
471
                $folder = $reflectionProperty->getValue($node);
×
472
                $path = $folder->getInternalPath();
×
473
                return $path;
×
474
        }
475

476
        #[\Override]
477
        public function setConfigPath(string $configPath): IEngineHandler {
478
                if (!$configPath) {
8✔
479
                        $this->appConfig->deleteKey(Application::APP_ID, 'config_path');
×
480
                } else {
481
                        if (!is_dir($configPath)) {
8✔
482
                                mkdir(
×
483
                                        directory: $configPath,
×
484
                                        recursive: true,
×
485
                                );
×
486
                        }
487
                        $this->appConfig->setValueString(Application::APP_ID, 'config_path', $configPath);
8✔
488
                }
489
                $this->configPath = $configPath;
8✔
490
                return $this;
8✔
491
        }
492

493
        public function getName(): string {
494
                $reflect = new ReflectionClass($this);
24✔
495
                $className = $reflect->getShortName();
24✔
496
                $name = strtolower(substr($className, 0, -7));
24✔
497
                return $name;
24✔
498
        }
499

500
        protected function getNames(): array {
501
                $names = [
74✔
502
                        'C' => $this->getCountry(),
74✔
503
                        'ST' => $this->getState(),
74✔
504
                        'L' => $this->getLocality(),
74✔
505
                        'O' => $this->getOrganization(),
74✔
506
                        'OU' => $this->getOrganizationalUnit(),
74✔
507
                ];
74✔
508
                if ($uid = $this->getUID()) {
74✔
509
                        $names['UID'] = $uid;
×
510
                }
511
                $names = array_filter($names, fn ($v) => !empty($v));
74✔
512
                return $names;
74✔
513
        }
514

515
        public function getUID(): string {
516
                return str_replace(' ', '+', $this->UID);
74✔
517
        }
518

519
        #[\Override]
520
        public function getLeafExpiryInDays(): int {
521
                if ($this->leafExpiryOverrideInDays !== null) {
27✔
522
                        return $this->leafExpiryOverrideInDays;
×
523
                }
524
                $exp = $this->appConfig->getValueInt(Application::APP_ID, 'expiry_in_days', 365);
27✔
525
                return $exp > 0 ? $exp : 365;
27✔
526
        }
527

528
        #[\Override]
529
        public function setLeafExpiryOverrideInDays(?int $days): self {
530
                $this->leafExpiryOverrideInDays = ($days !== null && $days > 0) ? $days : null;
×
531
                return $this;
×
532
        }
533

534
        #[\Override]
535
        public function getCaExpiryInDays(): int {
536
                $exp = $this->appConfig->getValueInt(Application::APP_ID, 'ca_expiry_in_days', 3650); // 10 years
62✔
537
                return $exp > 0 ? $exp : 3650;
62✔
538
        }
539

540
        private function getCertificatePolicy(): array {
541
                $return = ['policySection' => []];
11✔
542
                $oid = $this->certificatePolicyService->getOid();
11✔
543
                $cps = $this->certificatePolicyService->getCps();
11✔
544
                if ($oid && $cps) {
11✔
545
                        $return['policySection'][] = [
×
546
                                'OID' => $oid,
×
547
                                'CPS' => $cps,
×
548
                        ];
×
549
                }
550
                return $return;
11✔
551
        }
552

553
        abstract protected function getConfigureCheckResourceName(): string;
554

555
        abstract protected function getCertificateRegenerationTip(): string;
556

557
        abstract protected function getEngineSpecificChecks(): array;
558

559
        abstract protected function getSetupSuccessMessage(): string;
560

561
        abstract protected function getSetupErrorMessage(): string;
562

563
        abstract protected function getSetupErrorTip(): string;
564

565
        #[\Override]
566
        public function configureCheck(): array {
567
                $checks = $this->getEngineSpecificChecks();
8✔
568

569
                if (!$this->isSetupOk()) {
8✔
570
                        return array_merge($checks, [
1✔
571
                                (new ConfigureCheckHelper())
1✔
572
                                        ->setErrorMessage($this->getSetupErrorMessage())
1✔
573
                                        ->setResource($this->getConfigureCheckResourceName())
1✔
574
                                        ->setTip($this->getSetupErrorTip())
1✔
575
                        ]);
1✔
576
                }
577

578
                $checks[] = (new ConfigureCheckHelper())
7✔
579
                        ->setSuccessMessage($this->getSetupSuccessMessage())
7✔
580
                        ->setResource($this->getConfigureCheckResourceName());
7✔
581

582
                $modernFeaturesCheck = $this->checkRootCertificateModernFeatures();
7✔
583
                if ($modernFeaturesCheck) {
7✔
584
                        $checks[] = $modernFeaturesCheck;
7✔
585
                }
586

587
                $expiryCheck = $this->checkRootCertificateExpiry();
7✔
588
                if ($expiryCheck) {
7✔
589
                        $checks[] = $expiryCheck;
5✔
590
                }
591

592
                return $checks;
7✔
593
        }
594

595
        protected function checkRootCertificateModernFeatures(): ?ConfigureCheckHelper {
596
                $configPath = $this->getCurrentConfigPath();
7✔
597
                $caCertPath = $configPath . DIRECTORY_SEPARATOR . 'ca.pem';
7✔
598

599
                try {
600
                        $certContent = file_get_contents($caCertPath);
7✔
601
                        if (!$certContent) {
7✔
602
                                return (new ConfigureCheckHelper())
×
603
                                        ->setErrorMessage('Failed to read root certificate file')
×
604
                                        ->setResource($this->getConfigureCheckResourceName())
×
605
                                        ->setTip('Check file permissions and disk space');
×
606
                        }
607

608
                        $x509Resource = openssl_x509_read($certContent);
7✔
609
                        if (!$x509Resource) {
7✔
610
                                return (new ConfigureCheckHelper())
×
611
                                        ->setErrorMessage('Failed to parse root certificate')
×
612
                                        ->setResource($this->getConfigureCheckResourceName())
×
613
                                        ->setTip('Root certificate file may be corrupted or invalid');
×
614
                        }
615

616
                        $parsed = openssl_x509_parse($x509Resource);
7✔
617
                        if (!$parsed) {
7✔
618
                                return (new ConfigureCheckHelper())
×
619
                                        ->setErrorMessage('Failed to extract root certificate information')
×
620
                                        ->setResource($this->getConfigureCheckResourceName())
×
621
                                        ->setTip('Root certificate may be in an unsupported format');
×
622
                        }
623

624
                        $criticalIssues = [];
7✔
625
                        $minorIssues = [];
7✔
626

627
                        if (isset($parsed['serialNumber'])) {
7✔
628
                                $serialNumber = $parsed['serialNumber'];
7✔
629
                                $serialDecimal = hexdec($serialNumber);
7✔
630
                                if ($serialDecimal <= 1) {
7✔
631
                                        $minorIssues[] = 'Serial number is simple (zero or one)';
×
632
                                }
633
                        } else {
634
                                $criticalIssues[] = 'Serial number is missing';
×
635
                        }
636

637
                        $missingExtensions = [];
7✔
638
                        if (!isset($parsed['extensions']['subjectKeyIdentifier'])) {
7✔
639
                                $missingExtensions[] = 'Subject Key Identifier (SKI)';
×
640
                        }
641

642
                        $isSelfSigned = (isset($parsed['issuer']) && isset($parsed['subject'])
7✔
643
                                                        && $parsed['issuer'] === $parsed['subject']);
7✔
644

645
                        /**
646
                         * @todo workarround for missing AKI at certificates generated by CFSSL.
647
                         *
648
                         * CFSSL does not add Authority Key Identifier (AKI) to self-signed root certificates.
649
                         */
650
                        if (!$isSelfSigned && !isset($parsed['extensions']['authorityKeyIdentifier'])) {
7✔
651
                                $missingExtensions[] = 'Authority Key Identifier (AKI)';
×
652
                        }
653

654
                        if (!isset($parsed['extensions']['crlDistributionPoints'])) {
7✔
655
                                $missingExtensions[] = 'CRL Distribution Points';
×
656
                        }
657

658
                        if (!empty($missingExtensions)) {
7✔
659
                                $extensionsList = implode(', ', $missingExtensions);
×
660
                                $minorIssues[] = "Missing modern extensions: {$extensionsList}";
×
661
                        }
662

663
                        $hasLibresignCaUuid = $this->validateLibresignCaUuidInCertificate($parsed);
7✔
664
                        if (!$hasLibresignCaUuid) {
7✔
665
                                $minorIssues[] = 'LibreSign CA UUID not found in Organizational Unit';
7✔
666
                        }
667

668
                        if (!empty($criticalIssues)) {
7✔
669
                                $issuesList = implode(', ', $criticalIssues);
×
670
                                return (new ConfigureCheckHelper())
×
671
                                        ->setErrorMessage("Root certificate has critical issues: {$issuesList}")
×
672
                                        ->setResource($this->getConfigureCheckResourceName())
×
673
                                        ->setTip($this->getCertificateRegenerationTip());
×
674
                        }
675

676
                        if (!empty($minorIssues)) {
7✔
677
                                $issuesList = implode(', ', $minorIssues);
7✔
678
                                return (new ConfigureCheckHelper())
7✔
679
                                        ->setInfoMessage("Root certificate could benefit from modern features: {$issuesList}")
7✔
680
                                        ->setResource($this->getConfigureCheckResourceName())
7✔
681
                                        ->setTip($this->getCertificateRegenerationTip() . ' (recommended but not required)');
7✔
682
                        }
683

684
                        return null;
×
685

686
                } catch (\Exception $e) {
×
687
                        return (new ConfigureCheckHelper())
×
688
                                ->setErrorMessage('Failed to analyze root certificate: ' . $e->getMessage())
×
689
                                ->setResource($this->getConfigureCheckResourceName())
×
690
                                ->setTip('Check if the root certificate file is valid');
×
691
                }
692
        }
693

694
        private function validateLibresignCaUuidInCertificate(array $parsed): bool {
695
                if (!isset($parsed['subject']['OU'])) {
7✔
696
                        return false;
7✔
697
                }
698

699
                $instanceId = $this->getLibreSignInstanceId();
×
700
                if (empty($instanceId)) {
×
701
                        return false;
×
702
                }
703

704
                $organizationalUnits = $parsed['subject']['OU'];
×
705

706
                if (is_string($organizationalUnits)) {
×
707
                        if (str_contains($organizationalUnits, ', ')) {
×
708
                                $organizationalUnits = explode(', ', $organizationalUnits);
×
709
                        } else {
710
                                $organizationalUnits = [$organizationalUnits];
×
711
                        }
712
                }
713

714
                foreach ($organizationalUnits as $ou) {
×
715
                        $ou = trim($ou);
×
716
                        if ($this->caIdentifierService->isValidCaId($ou, $instanceId)) {
×
717
                                return true;
×
718
                        }
719
                }
720

721
                return false;
×
722
        }
723

724
        private function getLibreSignInstanceId(): string {
725
                $instanceId = $this->appConfig->getValueString(Application::APP_ID, 'instance_id', '');
×
726
                if (strlen($instanceId) === 10) {
×
727
                        return $instanceId;
×
728
                }
729
                return '';
×
730
        }
731

732
        private function calculateRemainingDays(int $validToTimestamp): int {
733
                $secondsPerDay = 60 * 60 * 24;
27✔
734
                $remainingSeconds = $validToTimestamp - time();
27✔
735
                return (int)ceil($remainingSeconds / $secondsPerDay);
27✔
736
        }
737

738
        protected function checkRootCertificateExpiry(): ?ConfigureCheckHelper {
739
                $configPath = $this->getCurrentConfigPath();
7✔
740
                $caCertPath = $configPath . DIRECTORY_SEPARATOR . 'ca.pem';
7✔
741

742
                if (!file_exists($caCertPath)) {
7✔
743
                        return null;
×
744
                }
745

746
                $certContent = file_get_contents($caCertPath);
7✔
747
                if (!$certContent) {
7✔
748
                        return null;
×
749
                }
750

751
                $x509Resource = openssl_x509_read($certContent);
7✔
752
                if (!$x509Resource) {
7✔
753
                        return null;
×
754
                }
755

756
                $parsed = openssl_x509_parse($x509Resource);
7✔
757
                if (!$parsed) {
7✔
758
                        return null;
×
759
                }
760

761
                $remainingDays = $this->calculateRemainingDays($parsed['validTo_time_t']);
7✔
762
                $leafExpiryDays = $this->getLeafExpiryInDays();
7✔
763

764
                if ($remainingDays < 0) {
7✔
765
                        return (new ConfigureCheckHelper())
×
766
                                ->setErrorMessage('Root certificate has expired')
×
767
                                ->setResource($this->getConfigureCheckResourceName())
×
768
                                ->setTip($this->getCertificateRegenerationTip() . ' URGENT: Certificate is expired!');
×
769
                }
770

771
                if ($remainingDays <= 7) {
7✔
772
                        return (new ConfigureCheckHelper())
2✔
773
                                ->setErrorMessage("Root certificate expires in {$remainingDays} days")
2✔
774
                                ->setResource($this->getConfigureCheckResourceName())
2✔
775
                                ->setTip($this->getCertificateRegenerationTip() . ' URGENT: Renew immediately!');
2✔
776
                }
777

778
                if ($remainingDays <= 30) {
5✔
779
                        return (new ConfigureCheckHelper())
1✔
780
                                ->setErrorMessage("Root certificate expires in {$remainingDays} days")
1✔
781
                                ->setResource($this->getConfigureCheckResourceName())
1✔
782
                                ->setTip($this->getCertificateRegenerationTip() . ' Renewal recommended soon.');
1✔
783
                }
784

785
                if ($remainingDays <= $leafExpiryDays) {
4✔
786
                        return (new ConfigureCheckHelper())
2✔
787
                                ->setInfoMessage("Root certificate expires in {$remainingDays} days (leaf validity: {$leafExpiryDays} days)")
2✔
788
                                ->setResource($this->getConfigureCheckResourceName())
2✔
789
                                ->setTip('Root certificate should be renewed to ensure it can sign CRLs for all issued leaf certificates.');
2✔
790
                }
791

792
                return null;
2✔
793
        }
794

795
        #[\Override]
796
        public function toArray(): array {
797
                $generated = $this->isSetupOk();
11✔
798
                $return = [
11✔
799
                        'configPath' => $this->getConfigPathForApi($generated),
11✔
800
                        'generated' => $generated,
11✔
801
                        'rootCert' => [
11✔
802
                                'commonName' => $this->getCommonName(),
11✔
803
                                'names' => [],
11✔
804
                        ],
11✔
805
                ];
11✔
806
                $return = array_merge(
11✔
807
                        $return,
11✔
808
                        $this->getCertificatePolicy(),
11✔
809
                );
11✔
810
                $names = $this->getNames();
11✔
811
                foreach ($names as $name => $value) {
11✔
812
                        $return['rootCert']['names'][] = [
6✔
813
                                'id' => $name,
6✔
814
                                'value' => $this->filterNameValue($name, $value, $generated),
6✔
815
                        ];
6✔
816
                }
817
                return $return;
11✔
818
        }
819

820
        private function getConfigPathForApi(bool $generated): string {
821
                return $generated ? $this->getCurrentConfigPath() : '';
11✔
822
        }
823

824
        private function filterNameValue(string $name, mixed $value, bool $generated): mixed {
825
                if ($name === 'OU' && is_array($value) && !$generated) {
6✔
826
                        $filtered = $this->removeCaIdFromOrganizationalUnit($value);
3✔
827
                        return empty($filtered) ? null : $filtered;
3✔
828
                }
829
                return $value;
6✔
830
        }
831

832
        private function removeCaIdFromOrganizationalUnit(array $organizationalUnits): array {
833
                $filtered = array_filter(
3✔
834
                        $organizationalUnits,
3✔
835
                        fn ($item) => !str_starts_with($item, 'libresign-ca-id:')
3✔
836
                );
3✔
837
                return array_values($filtered);
3✔
838
        }
839

840
        protected function getCrlDistributionUrl(): string {
841
                $caIdParsed = $this->caIdentifierService->getCaIdParsed();
63✔
842
                return $this->urlGenerator->linkToRouteAbsolute('libresign.crl.getRevocationList', [
63✔
843
                        'instanceId' => $caIdParsed['instanceId'],
63✔
844
                        'generation' => $caIdParsed['generation'],
63✔
845
                        'engineType' => $caIdParsed['engineType'],
63✔
846
                ]);
63✔
847
        }
848

849
        #[\Override]
850
        public function generateCrlDer(array $revokedCertificates, string $instanceId, int $generation, int $crlNumber): string {
851
                $configPath = $this->getConfigPathByParams($instanceId, $generation);
20✔
852
                $issuer = $this->loadCaIssuer($configPath);
18✔
853
                $signedCrl = $this->createAndSignCrl($issuer, $revokedCertificates, $crlNumber);
18✔
854
                $crlDerData = $this->saveCrlToDer($signedCrl, $configPath);
18✔
855

856
                return $crlDerData;
18✔
857
        }
858

859
        private function loadCaIssuer(string $configPath): \phpseclib3\File\X509 {
860
                $caCertPath = $configPath . DIRECTORY_SEPARATOR . 'ca.pem';
18✔
861
                $caKeyPath = $configPath . DIRECTORY_SEPARATOR . 'ca-key.pem';
18✔
862

863
                if (!file_exists($caCertPath) || !file_exists($caKeyPath)) {
18✔
864
                        $this->logger->error('CA certificate or private key not found', ['caCertPath' => $caCertPath, 'caKeyPath' => $caKeyPath]);
×
865
                        throw new \RuntimeException('CA certificate or private key not found. Run: occ libresign:configure:openssl');
×
866
                }
867

868
                $caCert = file_get_contents($caCertPath);
18✔
869
                $caKey = file_get_contents($caKeyPath);
18✔
870

871
                if (!$caCert || !$caKey) {
18✔
872
                        $this->logger->error('Failed to read CA certificate or private key', ['caCertPath' => $caCertPath, 'caKeyPath' => $caKeyPath]);
×
873
                        throw new \RuntimeException('Failed to read CA certificate or private key');
×
874
                }
875

876
                $issuer = new \phpseclib3\File\X509();
18✔
877
                $issuer->loadX509($caCert);
18✔
878
                $caPrivateKey = \phpseclib3\Crypt\PublicKeyLoader::load($caKey);
18✔
879

880
                if (!$caPrivateKey instanceof \phpseclib3\Crypt\Common\PrivateKey) {
18✔
881
                        $this->logger->error('Loaded key is not a private key', ['keyType' => get_class($caPrivateKey)]);
×
882
                        throw new \RuntimeException('Loaded key is not a private key');
×
883
                }
884

885
                $issuer->setPrivateKey($caPrivateKey);
18✔
886
                return $issuer;
18✔
887
        }
888

889
        private function createAndSignCrl(\phpseclib3\File\X509 $issuer, array $revokedCertificates, int $crlNumber): array {
890
                $utcZone = new \DateTimeZone('UTC');
18✔
891
                $crlToSign = new \phpseclib3\File\X509();
18✔
892
                $crlToSign->setSerialNumber((string)$crlNumber, 10);
18✔
893
                $crlToSign->setStartDate(new \DateTime('now', $utcZone));
18✔
894
                $crlToSign->setEndDate(new \DateTime('+7 days', $utcZone));
18✔
895

896
                $initialCrl = $crlToSign->signCRL($issuer, $crlToSign);
18✔
897
                if ($initialCrl === false) {
18✔
898
                        $this->logger->error('Failed to create initial CRL structure');
×
899
                        throw new \RuntimeException('Failed to create initial CRL structure');
×
900
                }
901

902
                if (!empty($revokedCertificates)) {
18✔
903
                        $savedCrl = $crlToSign->saveCRL($initialCrl);
17✔
904
                        if ($savedCrl === false) {
17✔
905
                                $this->logger->error('Failed to save initial CRL structure');
×
906
                                throw new \RuntimeException('Failed to save initial CRL structure');
×
907
                        }
908

909
                        $crlToSign->loadCRL($savedCrl);
17✔
910

911
                        $dateFormat = 'D, d M Y H:i:s O';
17✔
912
                        foreach ($revokedCertificates as $cert) {
17✔
913
                                $serialNumber = $cert->getSerialNumber();
17✔
914
                                $normalizedSerial = ltrim($serialNumber, '0') ?: '0';
17✔
915
                                $crlToSign->revoke(
17✔
916
                                        new \phpseclib3\Math\BigInteger($normalizedSerial, 16),
17✔
917
                                        $cert->getRevokedAt()->format($dateFormat)
17✔
918
                                );
17✔
919
                        }
920

921
                        $signedCrl = $crlToSign->signCRL($issuer, $crlToSign);
17✔
922
                } else {
923
                        $signedCrl = $initialCrl;
1✔
924
                }
925

926
                if ($signedCrl === false) {
18✔
927
                        $this->logger->error('Failed to sign CRL', ['crlNumber' => $crlNumber]);
×
928
                        throw new \RuntimeException('Failed to sign CRL');
×
929
                }
930

931
                if (!isset($signedCrl['signatureAlgorithm'])) {
18✔
932
                        $signedCrl['signatureAlgorithm'] = ['algorithm' => 'sha256WithRSAEncryption'];
×
933
                }
934

935
                return $signedCrl;
18✔
936
        }
937

938
        private function saveCrlToDer(array $signedCrl, string $configPath): string {
939
                $crlDerPath = $configPath . DIRECTORY_SEPARATOR . 'crl.der';
18✔
940
                $crlToSign = new \phpseclib3\File\X509();
18✔
941

942
                $crlDerData = $crlToSign->saveCRL($signedCrl, \phpseclib3\File\X509::FORMAT_DER);
18✔
943

944
                if ($crlDerData === false) {
18✔
945
                        $this->logger->error('Failed to save CRL in DER format');
×
946
                        throw new \RuntimeException('Failed to save CRL in DER format');
×
947
                }
948

949
                if (file_put_contents($crlDerPath, $crlDerData) === false) {
18✔
950
                        $this->logger->error('Failed to write CRL DER file', ['path' => $crlDerPath]);
×
951
                        throw new \RuntimeException('Failed to write CRL DER file');
×
952
                }
953

954
                return $crlDerData;
18✔
955
        }
956

957
        #[\Override]
958
        public function validateRootCertificate(): void {
959
                $configPath = $this->getCurrentConfigPath();
23✔
960
                if (empty($configPath)) {
23✔
961
                        return;
×
962
                }
963

964
                if (!is_dir($configPath)) {
23✔
965
                        return;
×
966
                }
967

968
                $rootCertPath = $configPath . DIRECTORY_SEPARATOR . 'ca.pem';
23✔
969

970
                if (!file_exists($rootCertPath)) {
23✔
971
                        return;
3✔
972
                }
973

974
                $rootCert = file_get_contents($rootCertPath);
20✔
975
                if (empty($rootCert)) {
20✔
976
                        return;
×
977
                }
978

979
                $certificate = openssl_x509_read($rootCert);
20✔
980
                if ($certificate === false) {
20✔
981
                        throw new LibresignException('Invalid root certificate content');
×
982
                }
983
                $certInfo = openssl_x509_parse($certificate);
20✔
984
                if ($certInfo === false) {
20✔
985
                        throw new LibresignException('Failed to parse root certificate');
×
986
                }
987

988
                if ($this->checkCertificateRevoked($certInfo['serialNumber'])) {
20✔
989
                        $this->logger->error('Root certificate has been revoked', [
×
990
                                'ca_id' => $this->getCaId(),
×
991
                                'impact' => 'all_leaf_certificates_invalid',
×
992
                        ]);
×
993
                        throw new LibresignException(
×
994
                                'Root certificate has been revoked. Please contact the administrator to regenerate the signing certificate.',
×
995
                                \OC\AppFramework\Http::STATUS_PRECONDITION_FAILED
×
996
                        );
×
997
                }
998

999
                if ($certInfo['validTo_time_t'] < time()) {
20✔
1000
                        $this->logger->error('Root certificate has expired', [
×
1001
                                'ca_id' => $this->getCaId(),
×
1002
                        ]);
×
1003
                        throw new LibresignException(
×
1004
                                'Root certificate has expired. Please contact the administrator to regenerate the signing certificate.',
×
1005
                                \OC\AppFramework\Http::STATUS_PRECONDITION_FAILED
×
1006
                        );
×
1007
                }
1008

1009
                $remainingDays = $this->calculateRemainingDays($certInfo['validTo_time_t']);
20✔
1010
                $leafExpiryDays = $this->getLeafExpiryInDays();
20✔
1011

1012
                if ($remainingDays <= $leafExpiryDays) {
20✔
1013
                        $this->logger->warning('Root certificate renewal needed', [
6✔
1014
                                'remaining_days' => $remainingDays,
6✔
1015
                                'leaf_expiry_days' => $leafExpiryDays,
6✔
1016
                        ]);
6✔
1017
                }
1018
        }
1019

1020
        private function checkCertificateRevoked(string $serialNumber): bool {
1021
                try {
1022
                        /** @var \OCA\Libresign\Service\Crl\CrlService */
1023
                        $crlService = \OC::$server->get(\OCA\Libresign\Service\Crl\CrlService::class);
20✔
1024
                        $status = $crlService->getCertificateStatus($serialNumber);
20✔
1025
                        return $status['status'] === 'revoked';
20✔
1026
                } catch (\Exception $e) {
×
1027
                        $this->logger->warning('Failed to check root certificate revocation status', [
×
1028
                                'error' => $e->getMessage()
×
1029
                        ]);
×
1030
                        return false;
×
1031
                }
1032
        }
1033
}
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