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

LibreSign / libresign / 27467144385

13 Jun 2026 12:45PM UTC coverage: 56.926%. First build
27467144385

Pull #7740

github

web-flow
Merge ed57780d5 into ae3c9e54d
Pull Request #7740: chore: migrate to PHP 8.3

56 of 75 new or added lines in 20 files covered. (74.67%)

10779 of 18935 relevant lines covered (56.93%)

7.01 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
        ) {
91
                $this->appData = $appDataFactory->get('libresign');
155✔
92
                $this->crlDistributionPointsExtractor = new CrlDistributionPointsExtractor();
155✔
93
        }
94

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

119
                return $certContent;
8✔
120
        }
121

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

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

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

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

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

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

167
                $return = self::convertArrayToUtf8($parsed);
11✔
168

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

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

182
                $this->addCrlValidationInfo($return, $x509);
11✔
183

184
                return $return;
11✔
185
        }
186

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

376
                return $pkiPath;
18✔
377
        }
378

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

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

393
                return $pkiPath;
68✔
394
        }
395

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

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

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

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

418
                return $ownerInfo;
68✔
419
        }
420

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

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

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

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

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

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

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

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

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

509
        abstract protected function getConfigureCheckResourceName(): string;
510

511
        abstract protected function getCertificateRegenerationTip(): string;
512

513
        abstract protected function getEngineSpecificChecks(): array;
514

515
        abstract protected function getSetupSuccessMessage(): string;
516

517
        abstract protected function getSetupErrorMessage(): string;
518

519
        abstract protected function getSetupErrorTip(): string;
520

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

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

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

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

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

548
                return $checks;
7✔
549
        }
550

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

659
                $organizationalUnits = $parsed['subject']['OU'];
×
660

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

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

676
                return false;
×
677
        }
678

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

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

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

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

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

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

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

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

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

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

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

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

747
                return null;
2✔
748
        }
749

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

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

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

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

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

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

811
                return $crlDerData;
18✔
812
        }
813

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

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

823
                $caCert = file_get_contents($caCertPath);
18✔
824
                $caKey = file_get_contents($caKeyPath);
18✔
825

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

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

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

840
                $issuer->setPrivateKey($caPrivateKey);
18✔
841
                return $issuer;
18✔
842
        }
843

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

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

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

864
                        $crlToSign->loadCRL($savedCrl);
17✔
865

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

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

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

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

890
                return $signedCrl;
18✔
891
        }
892

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

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

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

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

909
                return $crlDerData;
18✔
910
        }
911

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

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

923
                $rootCertPath = $configPath . DIRECTORY_SEPARATOR . 'ca.pem';
23✔
924

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

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

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

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

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

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

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

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