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

LibreSign / libresign / 24935862995

25 Apr 2026 04:54PM UTC coverage: 56.537%. First build
24935862995

Pull #7605

github

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

47 of 53 new or added lines in 2 files covered. (88.68%)

10599 of 18747 relevant lines covered (56.54%)

6.87 hits per line

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

67.91
/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\CrlDistributionPointsExtractor;
21
use OCA\Libresign\Service\Crl\CrlRevocationChecker;
22
use OCP\Files\AppData\IAppDataFactory;
23
use OCP\Files\IAppData;
24
use OCP\Files\SimpleFS\ISimpleFolder;
25
use OCP\IAppConfig;
26
use OCP\IConfig;
27
use OCP\IDateTimeFormatter;
28
use OCP\ITempManager;
29
use OCP\IURLGenerator;
30
use OpenSSLAsymmetricKey;
31
use OpenSSLCertificate;
32
use Psr\Log\LoggerInterface;
33
use ReflectionClass;
34

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

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

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

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

120
                return $certContent;
8✔
121
        }
122

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

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

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

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

160
        #[\Override]
161
        public function parseCertificate(string $certificate): array {
162
                return $this->parseX509($certificate);
3✔
163
        }
164

165
        private function parseX509(string $x509): array {
166
                $parsed = openssl_x509_parse(openssl_x509_read($x509));
10✔
167

168
                $return = self::convertArrayToUtf8($parsed);
10✔
169

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

180
                $return['valid_from'] = $this->dateTimeFormatter->formatDateTime($parsed['validFrom_time_t']);
10✔
181
                $return['valid_to'] = $this->dateTimeFormatter->formatDateTime($parsed['validTo_time_t']);
10✔
182

183
                $this->addCrlValidationInfo($return, $x509);
10✔
184

185
                return $return;
10✔
186
        }
187

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

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

208
                $externalValidationEnabled = $this->appConfig->getValueBool(Application::APP_ID, 'crl_external_validation_enabled', true);
2✔
209
                $certData['crl_validation'] = $externalValidationEnabled
2✔
210
                        ? CrlValidationStatus::MISSING
2✔
NEW
211
                        : CrlValidationStatus::DISABLED;
×
212
                $certData['crl_urls'] = [];
2✔
213
        }
214

215
        private static function convertArrayToUtf8($array) {
216
                foreach ($array as $key => $value) {
10✔
217
                        if (is_array($value)) {
10✔
218
                                $array[$key] = self::convertArrayToUtf8($value);
10✔
219
                        } elseif (is_string($value)) {
10✔
220
                                $array[$key] = mb_convert_encoding($value, 'UTF-8', 'UTF-8');
10✔
221
                        }
222
                }
223
                return $array;
10✔
224
        }
225

226
        public function opensslPkcs12Read(string &$certificate, string $privateKey): array {
227
                openssl_pkcs12_read($certificate, $certContent, $privateKey);
9✔
228
                if (!empty($certContent)) {
9✔
229
                        return $certContent;
7✔
230
                }
231
                /**
232
                 * Reference:
233
                 *
234
                 * https://github.com/php/php-src/issues/12128
235
                 * https://www.php.net/manual/en/function.openssl-pkcs12-read.php#128992
236
                 */
237
                $msg = openssl_error_string();
2✔
238
                if ($msg === 'error:0308010C:digital envelope routines::unsupported') {
2✔
239
                        $tempPassword = $this->tempManager->getTemporaryFile();
×
240
                        $tempEncriptedOriginal = $this->tempManager->getTemporaryFile();
×
241
                        $tempEncriptedRepacked = $this->tempManager->getTemporaryFile();
×
242
                        $tempDecrypted = $this->tempManager->getTemporaryFile();
×
243
                        file_put_contents($tempPassword, $privateKey);
×
244
                        file_put_contents($tempEncriptedOriginal, $certificate);
×
245
                        shell_exec(<<<REPACK_COMMAND
×
246
                                cat $tempPassword | openssl pkcs12 -legacy -in $tempEncriptedOriginal -nodes -out $tempDecrypted -passin stdin &&
×
247
                                cat $tempPassword | openssl pkcs12 -in $tempDecrypted -export -out $tempEncriptedRepacked -passout stdin
×
248
                                REPACK_COMMAND
×
249
                        );
×
250
                        $certificateRepacked = file_get_contents($tempEncriptedRepacked);
×
251
                        openssl_pkcs12_read($certificateRepacked, $certContent, $privateKey);
×
252
                        if (!empty($certContent)) {
×
253
                                $certificate = $certificateRepacked;
×
254
                                return $certContent;
×
255
                        }
256
                }
257
                throw new InvalidPasswordException();
2✔
258
        }
259

260
        /**
261
         * @param (int|string) $name
262
         *
263
         * @psalm-param array-key $name
264
         */
265
        public function translateToLong($name): string {
266
                return match ($name) {
3✔
267
                        'CN' => 'CommonName',
×
268
                        'C' => 'Country',
3✔
269
                        'ST' => 'State',
×
270
                        'L' => 'Locality',
×
271
                        'O' => 'Organization',
3✔
272
                        'OU' => 'OrganizationalUnit',
2✔
273
                        'UID' => 'UserIdentifier',
×
274
                        default => '',
3✔
275
                };
3✔
276
        }
277

278
        #[\Override]
279
        public function setEngine(string $engine): void {
280
                $this->appConfig->setValueString(Application::APP_ID, 'certificate_engine', $engine);
16✔
281
                $this->engine = $engine;
16✔
282
                $this->configureIdentifyMethodsForEngine($engine);
16✔
283
        }
284

285
        /**
286
         * Configure identification methods based on the certificate engine.
287
         *
288
         * When the engine is set to 'none', only the 'account' identification method
289
         * is allowed. This is because:
290
         * - The 'none' engine doesn't generate digital certificates
291
         * - Without certificates, only basic password authentication is viable
292
         * - The 'account' method ensures users authenticate with their Nextcloud credentials
293
         *
294
         * For other engines (openssl, cfssl, java), the identification methods remain
295
         * unchanged to preserve existing configurations.
296
         *
297
         * @param string $engine The certificate engine name (i.e. 'none', 'openssl', 'cfssl')
298
         */
299
        private function configureIdentifyMethodsForEngine(string $engine): void {
300
                if ($engine !== 'none') {
16✔
301
                        return;
10✔
302
                }
303

304
                $config = [[
6✔
305
                        'name' => 'account',
6✔
306
                        'enabled' => true,
6✔
307
                        'mandatory' => true,
6✔
308
                ]];
6✔
309
                $this->appConfig->setValueArray(Application::APP_ID, 'identify_methods', $config);
6✔
310
        }
311

312
        #[\Override]
313
        public function getEngine(): string {
314
                if ($this->engine) {
3✔
315
                        return $this->engine;
3✔
316
                }
317
                $this->engine = $this->appConfig->getValueString(Application::APP_ID, 'certificate_engine', 'openssl');
×
318
                return $this->engine;
×
319
        }
320

321
        #[\Override]
322
        public function populateInstance(array $rootCert): IEngineHandler {
323
                if (empty($rootCert)) {
24✔
324
                        $rootCert = $this->appConfig->getValueArray(Application::APP_ID, 'rootCert');
24✔
325
                }
326
                if (!$rootCert) {
24✔
327
                        return $this;
24✔
328
                }
329
                if (!empty($rootCert['names'])) {
×
330
                        foreach ($rootCert['names'] as $id => $customName) {
×
331
                                $longCustomName = $this->translateToLong($id);
×
332
                                // Prevent to save a property that don't exists
333
                                if (!property_exists($this, lcfirst($longCustomName))) {
×
334
                                        continue;
×
335
                                }
336
                                $this->{'set' . ucfirst($longCustomName)}($customName['value']);
×
337
                        }
338
                }
339
                if (!$this->getCommonName()) {
×
340
                        $this->setCommonName($rootCert['commonName']);
×
341
                }
342
                return $this;
×
343
        }
344

345
        #[\Override]
346
        public function getCurrentConfigPath(): string {
347
                if ($this->configPath) {
78✔
348
                        return $this->configPath;
72✔
349
                }
350

351
                $customConfigPath = $this->appConfig->getValueString(Application::APP_ID, 'config_path');
68✔
352
                if ($customConfigPath && is_dir($customConfigPath)) {
68✔
353
                        $this->configPath = $customConfigPath;
10✔
354
                        return $this->configPath;
10✔
355
                }
356

357
                $this->configPath = $this->initializePkiConfigPath();
68✔
358
                if (!empty($this->configPath)) {
68✔
359
                        $this->appConfig->setValueString(Application::APP_ID, 'config_path', $this->configPath);
68✔
360
                }
361
                return $this->configPath;
68✔
362
        }
363

364
        #[\Override]
365
        public function getConfigPathByParams(string $instanceId, int $generation): string {
366
                $engineName = $this->getName();
20✔
367

368
                $pkiDirName = $this->caIdentifierService->generatePkiDirectoryNameFromParams($instanceId, $generation, $engineName);
20✔
369
                $dataDir = $this->config->getSystemValue('datadirectory', \OC::$SERVERROOT . '/data/');
20✔
370
                $systemInstanceId = $this->config->getSystemValue('instanceid');
20✔
371
                $pkiPath = $dataDir . '/appdata_' . $systemInstanceId . '/libresign/' . $pkiDirName;
20✔
372

373
                if (!is_dir($pkiPath)) {
20✔
374
                        throw new \RuntimeException("Config path does not exist for instanceId: {$instanceId}, generation: {$generation}");
2✔
375
                }
376

377
                return $pkiPath;
18✔
378
        }
379

380
        private function initializePkiConfigPath(): string {
381
                $caId = $this->getCaId();
68✔
382
                if (empty($caId)) {
68✔
383
                        return '';
×
384
                }
385
                $pkiDirName = $this->caIdentifierService->generatePkiDirectoryName($caId);
68✔
386
                $dataDir = $this->config->getSystemValue('datadirectory', \OC::$SERVERROOT . '/data/');
68✔
387
                $systemInstanceId = $this->config->getSystemValue('instanceid');
68✔
388
                $pkiPath = $dataDir . '/appdata_' . $systemInstanceId . '/libresign/' . $pkiDirName;
68✔
389

390
                if (!is_dir($pkiPath)) {
68✔
391
                        $this->createDirectoryWithCorrectOwnership($pkiPath);
68✔
392
                }
393

394
                return $pkiPath;
68✔
395
        }
396

397
        private function createDirectoryWithCorrectOwnership(string $path): void {
398
                $ownerInfo = $this->getFilesOwnerInfo();
68✔
399
                $fullCommand = 'mkdir -p ' . escapeshellarg($path);
68✔
400

401
                if (posix_getuid() !== $ownerInfo['uid']) {
68✔
402
                        $fullCommand = 'runuser -u ' . $ownerInfo['name'] . ' -- ' . $fullCommand;
×
403
                }
404

405
                exec($fullCommand);
68✔
406
        }
407

408
        private function getFilesOwnerInfo(): array {
409
                $currentFile = realpath(__DIR__);
68✔
410
                $owner = fileowner($currentFile);
68✔
411
                if ($owner === false) {
68✔
412
                        throw new \RuntimeException('Unable to get file information');
×
413
                }
414
                $ownerInfo = posix_getpwuid($owner);
68✔
415
                if ($ownerInfo === false) {
68✔
416
                        throw new \RuntimeException('Unable to get file owner information');
×
417
                }
418

419
                return $ownerInfo;
68✔
420
        }
421

422
        /**
423
         * @todo check a best solution to don't use reflection
424
         */
425
        private function getInternalPathOfFolder(ISimpleFolder $node): string {
426
                $reflection = new \ReflectionClass($node);
×
427
                $reflectionProperty = $reflection->getProperty('folder');
×
428
                $folder = $reflectionProperty->getValue($node);
×
429
                $path = $folder->getInternalPath();
×
430
                return $path;
×
431
        }
432

433
        #[\Override]
434
        public function setConfigPath(string $configPath): IEngineHandler {
435
                if (!$configPath) {
8✔
436
                        $this->appConfig->deleteKey(Application::APP_ID, 'config_path');
×
437
                } else {
438
                        if (!is_dir($configPath)) {
8✔
439
                                mkdir(
×
440
                                        directory: $configPath,
×
441
                                        recursive: true,
×
442
                                );
×
443
                        }
444
                        $this->appConfig->setValueString(Application::APP_ID, 'config_path', $configPath);
8✔
445
                }
446
                $this->configPath = $configPath;
8✔
447
                return $this;
8✔
448
        }
449

450
        public function getName(): string {
451
                $reflect = new ReflectionClass($this);
24✔
452
                $className = $reflect->getShortName();
24✔
453
                $name = strtolower(substr($className, 0, -7));
24✔
454
                return $name;
24✔
455
        }
456

457
        protected function getNames(): array {
458
                $names = [
74✔
459
                        'C' => $this->getCountry(),
74✔
460
                        'ST' => $this->getState(),
74✔
461
                        'L' => $this->getLocality(),
74✔
462
                        'O' => $this->getOrganization(),
74✔
463
                        'OU' => $this->getOrganizationalUnit(),
74✔
464
                ];
74✔
465
                if ($uid = $this->getUID()) {
74✔
466
                        $names['UID'] = $uid;
×
467
                }
468
                $names = array_filter($names, fn ($v) => !empty($v));
74✔
469
                return $names;
74✔
470
        }
471

472
        public function getUID(): string {
473
                return str_replace(' ', '+', $this->UID);
74✔
474
        }
475

476
        #[\Override]
477
        public function getLeafExpiryInDays(): int {
478
                if ($this->leafExpiryOverrideInDays !== null) {
27✔
479
                        return $this->leafExpiryOverrideInDays;
×
480
                }
481
                $exp = $this->appConfig->getValueInt(Application::APP_ID, 'expiry_in_days', 365);
27✔
482
                return $exp > 0 ? $exp : 365;
27✔
483
        }
484

485
        #[\Override]
486
        public function setLeafExpiryOverrideInDays(?int $days): self {
487
                $this->leafExpiryOverrideInDays = ($days !== null && $days > 0) ? $days : null;
×
488
                return $this;
×
489
        }
490

491
        #[\Override]
492
        public function getCaExpiryInDays(): int {
493
                $exp = $this->appConfig->getValueInt(Application::APP_ID, 'ca_expiry_in_days', 3650); // 10 years
62✔
494
                return $exp > 0 ? $exp : 3650;
62✔
495
        }
496

497
        private function getCertificatePolicy(): array {
498
                $return = ['policySection' => []];
11✔
499
                $oid = $this->certificatePolicyService->getOid();
11✔
500
                $cps = $this->certificatePolicyService->getCps();
11✔
501
                if ($oid && $cps) {
11✔
502
                        $return['policySection'][] = [
×
503
                                'OID' => $oid,
×
504
                                'CPS' => $cps,
×
505
                        ];
×
506
                }
507
                return $return;
11✔
508
        }
509

510
        abstract protected function getConfigureCheckResourceName(): string;
511

512
        abstract protected function getCertificateRegenerationTip(): string;
513

514
        abstract protected function getEngineSpecificChecks(): array;
515

516
        abstract protected function getSetupSuccessMessage(): string;
517

518
        abstract protected function getSetupErrorMessage(): string;
519

520
        abstract protected function getSetupErrorTip(): string;
521

522
        #[\Override]
523
        public function configureCheck(): array {
524
                $checks = $this->getEngineSpecificChecks();
8✔
525

526
                if (!$this->isSetupOk()) {
8✔
527
                        return array_merge($checks, [
1✔
528
                                (new ConfigureCheckHelper())
1✔
529
                                        ->setErrorMessage($this->getSetupErrorMessage())
1✔
530
                                        ->setResource($this->getConfigureCheckResourceName())
1✔
531
                                        ->setTip($this->getSetupErrorTip())
1✔
532
                        ]);
1✔
533
                }
534

535
                $checks[] = (new ConfigureCheckHelper())
7✔
536
                        ->setSuccessMessage($this->getSetupSuccessMessage())
7✔
537
                        ->setResource($this->getConfigureCheckResourceName());
7✔
538

539
                $modernFeaturesCheck = $this->checkRootCertificateModernFeatures();
7✔
540
                if ($modernFeaturesCheck) {
7✔
541
                        $checks[] = $modernFeaturesCheck;
7✔
542
                }
543

544
                $expiryCheck = $this->checkRootCertificateExpiry();
7✔
545
                if ($expiryCheck) {
7✔
546
                        $checks[] = $expiryCheck;
5✔
547
                }
548

549
                return $checks;
7✔
550
        }
551

552
        protected function checkRootCertificateModernFeatures(): ?ConfigureCheckHelper {
553
                $configPath = $this->getCurrentConfigPath();
7✔
554
                $caCertPath = $configPath . DIRECTORY_SEPARATOR . 'ca.pem';
7✔
555

556
                try {
557
                        $certContent = file_get_contents($caCertPath);
7✔
558
                        if (!$certContent) {
7✔
559
                                return (new ConfigureCheckHelper())
×
560
                                        ->setErrorMessage('Failed to read root certificate file')
×
561
                                        ->setResource($this->getConfigureCheckResourceName())
×
562
                                        ->setTip('Check file permissions and disk space');
×
563
                        }
564

565
                        $x509Resource = openssl_x509_read($certContent);
7✔
566
                        if (!$x509Resource) {
7✔
567
                                return (new ConfigureCheckHelper())
×
568
                                        ->setErrorMessage('Failed to parse root certificate')
×
569
                                        ->setResource($this->getConfigureCheckResourceName())
×
570
                                        ->setTip('Root certificate file may be corrupted or invalid');
×
571
                        }
572

573
                        $parsed = openssl_x509_parse($x509Resource);
7✔
574
                        if (!$parsed) {
7✔
575
                                return (new ConfigureCheckHelper())
×
576
                                        ->setErrorMessage('Failed to extract root certificate information')
×
577
                                        ->setResource($this->getConfigureCheckResourceName())
×
578
                                        ->setTip('Root certificate may be in an unsupported format');
×
579
                        }
580

581
                        $criticalIssues = [];
7✔
582
                        $minorIssues = [];
7✔
583

584
                        if (isset($parsed['serialNumber'])) {
7✔
585
                                $serialNumber = $parsed['serialNumber'];
7✔
586
                                $serialDecimal = hexdec($serialNumber);
7✔
587
                                if ($serialDecimal <= 1) {
7✔
588
                                        $minorIssues[] = 'Serial number is simple (zero or one)';
×
589
                                }
590
                        } else {
591
                                $criticalIssues[] = 'Serial number is missing';
×
592
                        }
593

594
                        $missingExtensions = [];
7✔
595
                        if (!isset($parsed['extensions']['subjectKeyIdentifier'])) {
7✔
596
                                $missingExtensions[] = 'Subject Key Identifier (SKI)';
×
597
                        }
598

599
                        $isSelfSigned = (isset($parsed['issuer']) && isset($parsed['subject'])
7✔
600
                                                        && $parsed['issuer'] === $parsed['subject']);
7✔
601

602
                        /**
603
                         * @todo workarround for missing AKI at certificates generated by CFSSL.
604
                         *
605
                         * CFSSL does not add Authority Key Identifier (AKI) to self-signed root certificates.
606
                         */
607
                        if (!$isSelfSigned && !isset($parsed['extensions']['authorityKeyIdentifier'])) {
7✔
608
                                $missingExtensions[] = 'Authority Key Identifier (AKI)';
×
609
                        }
610

611
                        if (!isset($parsed['extensions']['crlDistributionPoints'])) {
7✔
612
                                $missingExtensions[] = 'CRL Distribution Points';
×
613
                        }
614

615
                        if (!empty($missingExtensions)) {
7✔
616
                                $extensionsList = implode(', ', $missingExtensions);
×
617
                                $minorIssues[] = "Missing modern extensions: {$extensionsList}";
×
618
                        }
619

620
                        $hasLibresignCaUuid = $this->validateLibresignCaUuidInCertificate($parsed);
7✔
621
                        if (!$hasLibresignCaUuid) {
7✔
622
                                $minorIssues[] = 'LibreSign CA UUID not found in Organizational Unit';
7✔
623
                        }
624

625
                        if (!empty($criticalIssues)) {
7✔
626
                                $issuesList = implode(', ', $criticalIssues);
×
627
                                return (new ConfigureCheckHelper())
×
628
                                        ->setErrorMessage("Root certificate has critical issues: {$issuesList}")
×
629
                                        ->setResource($this->getConfigureCheckResourceName())
×
630
                                        ->setTip($this->getCertificateRegenerationTip());
×
631
                        }
632

633
                        if (!empty($minorIssues)) {
7✔
634
                                $issuesList = implode(', ', $minorIssues);
7✔
635
                                return (new ConfigureCheckHelper())
7✔
636
                                        ->setInfoMessage("Root certificate could benefit from modern features: {$issuesList}")
7✔
637
                                        ->setResource($this->getConfigureCheckResourceName())
7✔
638
                                        ->setTip($this->getCertificateRegenerationTip() . ' (recommended but not required)');
7✔
639
                        }
640

641
                        return null;
×
642

643
                } catch (\Exception $e) {
×
644
                        return (new ConfigureCheckHelper())
×
645
                                ->setErrorMessage('Failed to analyze root certificate: ' . $e->getMessage())
×
646
                                ->setResource($this->getConfigureCheckResourceName())
×
647
                                ->setTip('Check if the root certificate file is valid');
×
648
                }
649
        }
650

651
        private function validateLibresignCaUuidInCertificate(array $parsed): bool {
652
                if (!isset($parsed['subject']['OU'])) {
7✔
653
                        return false;
7✔
654
                }
655

656
                $instanceId = $this->getLibreSignInstanceId();
×
657
                if (empty($instanceId)) {
×
658
                        return false;
×
659
                }
660

661
                $organizationalUnits = $parsed['subject']['OU'];
×
662

663
                if (is_string($organizationalUnits)) {
×
664
                        if (str_contains($organizationalUnits, ', ')) {
×
665
                                $organizationalUnits = explode(', ', $organizationalUnits);
×
666
                        } else {
667
                                $organizationalUnits = [$organizationalUnits];
×
668
                        }
669
                }
670

671
                foreach ($organizationalUnits as $ou) {
×
672
                        $ou = trim($ou);
×
673
                        if ($this->caIdentifierService->isValidCaId($ou, $instanceId)) {
×
674
                                return true;
×
675
                        }
676
                }
677

678
                return false;
×
679
        }
680

681
        private function getLibreSignInstanceId(): string {
682
                $instanceId = $this->appConfig->getValueString(Application::APP_ID, 'instance_id', '');
×
683
                if (strlen($instanceId) === 10) {
×
684
                        return $instanceId;
×
685
                }
686
                return '';
×
687
        }
688

689
        private function calculateRemainingDays(int $validToTimestamp): int {
690
                $secondsPerDay = 60 * 60 * 24;
27✔
691
                $remainingSeconds = $validToTimestamp - time();
27✔
692
                return (int)ceil($remainingSeconds / $secondsPerDay);
27✔
693
        }
694

695
        protected function checkRootCertificateExpiry(): ?ConfigureCheckHelper {
696
                $configPath = $this->getCurrentConfigPath();
7✔
697
                $caCertPath = $configPath . DIRECTORY_SEPARATOR . 'ca.pem';
7✔
698

699
                if (!file_exists($caCertPath)) {
7✔
700
                        return null;
×
701
                }
702

703
                $certContent = file_get_contents($caCertPath);
7✔
704
                if (!$certContent) {
7✔
705
                        return null;
×
706
                }
707

708
                $x509Resource = openssl_x509_read($certContent);
7✔
709
                if (!$x509Resource) {
7✔
710
                        return null;
×
711
                }
712

713
                $parsed = openssl_x509_parse($x509Resource);
7✔
714
                if (!$parsed) {
7✔
715
                        return null;
×
716
                }
717

718
                $remainingDays = $this->calculateRemainingDays($parsed['validTo_time_t']);
7✔
719
                $leafExpiryDays = $this->getLeafExpiryInDays();
7✔
720

721
                if ($remainingDays < 0) {
7✔
722
                        return (new ConfigureCheckHelper())
×
723
                                ->setErrorMessage('Root certificate has expired')
×
724
                                ->setResource($this->getConfigureCheckResourceName())
×
725
                                ->setTip($this->getCertificateRegenerationTip() . ' URGENT: Certificate is expired!');
×
726
                }
727

728
                if ($remainingDays <= 7) {
7✔
729
                        return (new ConfigureCheckHelper())
2✔
730
                                ->setErrorMessage("Root certificate expires in {$remainingDays} days")
2✔
731
                                ->setResource($this->getConfigureCheckResourceName())
2✔
732
                                ->setTip($this->getCertificateRegenerationTip() . ' URGENT: Renew immediately!');
2✔
733
                }
734

735
                if ($remainingDays <= 30) {
5✔
736
                        return (new ConfigureCheckHelper())
1✔
737
                                ->setErrorMessage("Root certificate expires in {$remainingDays} days")
1✔
738
                                ->setResource($this->getConfigureCheckResourceName())
1✔
739
                                ->setTip($this->getCertificateRegenerationTip() . ' Renewal recommended soon.');
1✔
740
                }
741

742
                if ($remainingDays <= $leafExpiryDays) {
4✔
743
                        return (new ConfigureCheckHelper())
2✔
744
                                ->setInfoMessage("Root certificate expires in {$remainingDays} days (leaf validity: {$leafExpiryDays} days)")
2✔
745
                                ->setResource($this->getConfigureCheckResourceName())
2✔
746
                                ->setTip('Root certificate should be renewed to ensure it can sign CRLs for all issued leaf certificates.');
2✔
747
                }
748

749
                return null;
2✔
750
        }
751

752
        #[\Override]
753
        public function toArray(): array {
754
                $generated = $this->isSetupOk();
11✔
755
                $return = [
11✔
756
                        'configPath' => $this->getConfigPathForApi($generated),
11✔
757
                        'generated' => $generated,
11✔
758
                        'rootCert' => [
11✔
759
                                'commonName' => $this->getCommonName(),
11✔
760
                                'names' => [],
11✔
761
                        ],
11✔
762
                ];
11✔
763
                $return = array_merge(
11✔
764
                        $return,
11✔
765
                        $this->getCertificatePolicy(),
11✔
766
                );
11✔
767
                $names = $this->getNames();
11✔
768
                foreach ($names as $name => $value) {
11✔
769
                        $return['rootCert']['names'][] = [
6✔
770
                                'id' => $name,
6✔
771
                                'value' => $this->filterNameValue($name, $value, $generated),
6✔
772
                        ];
6✔
773
                }
774
                return $return;
11✔
775
        }
776

777
        private function getConfigPathForApi(bool $generated): string {
778
                return $generated ? $this->getCurrentConfigPath() : '';
11✔
779
        }
780

781
        private function filterNameValue(string $name, mixed $value, bool $generated): mixed {
782
                if ($name === 'OU' && is_array($value) && !$generated) {
6✔
783
                        $filtered = $this->removeCaIdFromOrganizationalUnit($value);
3✔
784
                        return empty($filtered) ? null : $filtered;
3✔
785
                }
786
                return $value;
6✔
787
        }
788

789
        private function removeCaIdFromOrganizationalUnit(array $organizationalUnits): array {
790
                $filtered = array_filter(
3✔
791
                        $organizationalUnits,
3✔
792
                        fn ($item) => !str_starts_with($item, 'libresign-ca-id:')
3✔
793
                );
3✔
794
                return array_values($filtered);
3✔
795
        }
796

797
        protected function getCrlDistributionUrl(): string {
798
                $caIdParsed = $this->caIdentifierService->getCaIdParsed();
63✔
799
                return $this->urlGenerator->linkToRouteAbsolute('libresign.crl.getRevocationList', [
63✔
800
                        'instanceId' => $caIdParsed['instanceId'],
63✔
801
                        'generation' => $caIdParsed['generation'],
63✔
802
                        'engineType' => $caIdParsed['engineType'],
63✔
803
                ]);
63✔
804
        }
805

806
        #[\Override]
807
        public function generateCrlDer(array $revokedCertificates, string $instanceId, int $generation, int $crlNumber): string {
808
                $configPath = $this->getConfigPathByParams($instanceId, $generation);
20✔
809
                $issuer = $this->loadCaIssuer($configPath);
18✔
810
                $signedCrl = $this->createAndSignCrl($issuer, $revokedCertificates, $crlNumber);
18✔
811
                $crlDerData = $this->saveCrlToDer($signedCrl, $configPath);
18✔
812

813
                return $crlDerData;
18✔
814
        }
815

816
        private function loadCaIssuer(string $configPath): \phpseclib3\File\X509 {
817
                $caCertPath = $configPath . DIRECTORY_SEPARATOR . 'ca.pem';
18✔
818
                $caKeyPath = $configPath . DIRECTORY_SEPARATOR . 'ca-key.pem';
18✔
819

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

825
                $caCert = file_get_contents($caCertPath);
18✔
826
                $caKey = file_get_contents($caKeyPath);
18✔
827

828
                if (!$caCert || !$caKey) {
18✔
829
                        $this->logger->error('Failed to read CA certificate or private key', ['caCertPath' => $caCertPath, 'caKeyPath' => $caKeyPath]);
×
830
                        throw new \RuntimeException('Failed to read CA certificate or private key');
×
831
                }
832

833
                $issuer = new \phpseclib3\File\X509();
18✔
834
                $issuer->loadX509($caCert);
18✔
835
                $caPrivateKey = \phpseclib3\Crypt\PublicKeyLoader::load($caKey);
18✔
836

837
                if (!$caPrivateKey instanceof \phpseclib3\Crypt\Common\PrivateKey) {
18✔
838
                        $this->logger->error('Loaded key is not a private key', ['keyType' => get_class($caPrivateKey)]);
×
839
                        throw new \RuntimeException('Loaded key is not a private key');
×
840
                }
841

842
                $issuer->setPrivateKey($caPrivateKey);
18✔
843
                return $issuer;
18✔
844
        }
845

846
        private function createAndSignCrl(\phpseclib3\File\X509 $issuer, array $revokedCertificates, int $crlNumber): array {
847
                $utcZone = new \DateTimeZone('UTC');
18✔
848
                $crlToSign = new \phpseclib3\File\X509();
18✔
849
                $crlToSign->setSerialNumber((string)$crlNumber, 10);
18✔
850
                $crlToSign->setStartDate(new \DateTime('now', $utcZone));
18✔
851
                $crlToSign->setEndDate(new \DateTime('+7 days', $utcZone));
18✔
852

853
                $initialCrl = $crlToSign->signCRL($issuer, $crlToSign);
18✔
854
                if ($initialCrl === false) {
18✔
855
                        $this->logger->error('Failed to create initial CRL structure');
×
856
                        throw new \RuntimeException('Failed to create initial CRL structure');
×
857
                }
858

859
                if (!empty($revokedCertificates)) {
18✔
860
                        $savedCrl = $crlToSign->saveCRL($initialCrl);
17✔
861
                        if ($savedCrl === false) {
17✔
862
                                $this->logger->error('Failed to save initial CRL structure');
×
863
                                throw new \RuntimeException('Failed to save initial CRL structure');
×
864
                        }
865

866
                        $crlToSign->loadCRL($savedCrl);
17✔
867

868
                        $dateFormat = 'D, d M Y H:i:s O';
17✔
869
                        foreach ($revokedCertificates as $cert) {
17✔
870
                                $serialNumber = $cert->getSerialNumber();
17✔
871
                                $normalizedSerial = ltrim($serialNumber, '0') ?: '0';
17✔
872
                                $crlToSign->revoke(
17✔
873
                                        new \phpseclib3\Math\BigInteger($normalizedSerial, 16),
17✔
874
                                        $cert->getRevokedAt()->format($dateFormat)
17✔
875
                                );
17✔
876
                        }
877

878
                        $signedCrl = $crlToSign->signCRL($issuer, $crlToSign);
17✔
879
                } else {
880
                        $signedCrl = $initialCrl;
1✔
881
                }
882

883
                if ($signedCrl === false) {
18✔
884
                        $this->logger->error('Failed to sign CRL', ['crlNumber' => $crlNumber]);
×
885
                        throw new \RuntimeException('Failed to sign CRL');
×
886
                }
887

888
                if (!isset($signedCrl['signatureAlgorithm'])) {
18✔
889
                        $signedCrl['signatureAlgorithm'] = ['algorithm' => 'sha256WithRSAEncryption'];
×
890
                }
891

892
                return $signedCrl;
18✔
893
        }
894

895
        private function saveCrlToDer(array $signedCrl, string $configPath): string {
896
                $crlDerPath = $configPath . DIRECTORY_SEPARATOR . 'crl.der';
18✔
897
                $crlToSign = new \phpseclib3\File\X509();
18✔
898

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

901
                if ($crlDerData === false) {
18✔
902
                        $this->logger->error('Failed to save CRL in DER format');
×
903
                        throw new \RuntimeException('Failed to save CRL in DER format');
×
904
                }
905

906
                if (file_put_contents($crlDerPath, $crlDerData) === false) {
18✔
907
                        $this->logger->error('Failed to write CRL DER file', ['path' => $crlDerPath]);
×
908
                        throw new \RuntimeException('Failed to write CRL DER file');
×
909
                }
910

911
                return $crlDerData;
18✔
912
        }
913

914
        #[\Override]
915
        public function validateRootCertificate(): void {
916
                $configPath = $this->getCurrentConfigPath();
23✔
917
                if (empty($configPath)) {
23✔
918
                        return;
×
919
                }
920

921
                if (!is_dir($configPath)) {
23✔
922
                        return;
×
923
                }
924

925
                $rootCertPath = $configPath . DIRECTORY_SEPARATOR . 'ca.pem';
23✔
926

927
                if (!file_exists($rootCertPath)) {
23✔
928
                        return;
3✔
929
                }
930

931
                $rootCert = file_get_contents($rootCertPath);
20✔
932
                if (empty($rootCert)) {
20✔
933
                        return;
×
934
                }
935

936
                $certificate = openssl_x509_read($rootCert);
20✔
937
                if ($certificate === false) {
20✔
938
                        throw new LibresignException('Invalid root certificate content');
×
939
                }
940
                $certInfo = openssl_x509_parse($certificate);
20✔
941
                if ($certInfo === false) {
20✔
942
                        throw new LibresignException('Failed to parse root certificate');
×
943
                }
944

945
                if ($this->checkCertificateRevoked($certInfo['serialNumber'])) {
20✔
946
                        $this->logger->error('Root certificate has been revoked', [
×
947
                                'ca_id' => $this->getCaId(),
×
948
                                'impact' => 'all_leaf_certificates_invalid',
×
949
                        ]);
×
950
                        throw new LibresignException(
×
951
                                'Root certificate has been revoked. Please contact the administrator to regenerate the signing certificate.',
×
952
                                \OC\AppFramework\Http::STATUS_PRECONDITION_FAILED
×
953
                        );
×
954
                }
955

956
                if ($certInfo['validTo_time_t'] < time()) {
20✔
957
                        $this->logger->error('Root certificate has expired', [
×
958
                                'ca_id' => $this->getCaId(),
×
959
                        ]);
×
960
                        throw new LibresignException(
×
961
                                'Root certificate has expired. Please contact the administrator to regenerate the signing certificate.',
×
962
                                \OC\AppFramework\Http::STATUS_PRECONDITION_FAILED
×
963
                        );
×
964
                }
965

966
                $remainingDays = $this->calculateRemainingDays($certInfo['validTo_time_t']);
20✔
967
                $leafExpiryDays = $this->getLeafExpiryInDays();
20✔
968

969
                if ($remainingDays <= $leafExpiryDays) {
20✔
970
                        $this->logger->warning('Root certificate renewal needed', [
6✔
971
                                'remaining_days' => $remainingDays,
6✔
972
                                'leaf_expiry_days' => $leafExpiryDays,
6✔
973
                        ]);
6✔
974
                }
975
        }
976

977
        private function checkCertificateRevoked(string $serialNumber): bool {
978
                try {
979
                        /** @var \OCA\Libresign\Service\Crl\CrlService */
980
                        $crlService = \OC::$server->get(\OCA\Libresign\Service\Crl\CrlService::class);
20✔
981
                        $status = $crlService->getCertificateStatus($serialNumber);
20✔
982
                        return $status['status'] === 'revoked';
20✔
983
                } catch (\Exception $e) {
×
984
                        $this->logger->warning('Failed to check root certificate revocation status', [
×
985
                                'error' => $e->getMessage()
×
986
                        ]);
×
987
                        return false;
×
988
                }
989
        }
990
}
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