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

LibreSign / libresign / 19834429749

01 Dec 2025 07:12PM UTC coverage: 40.25%. First build
19834429749

Pull #5863

github

web-flow
Merge b5074c508 into 0f25c05f2
Pull Request #5863: feat: root certificate validation

39 of 58 new or added lines in 7 files covered. (67.24%)

4857 of 12067 relevant lines covered (40.25%)

3.74 hits per line

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

56.24
/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\Exception\EmptyCertificateException;
13
use OCA\Libresign\Exception\InvalidPasswordException;
14
use OCA\Libresign\Exception\LibresignException;
15
use OCA\Libresign\Helper\ConfigureCheckHelper;
16
use OCA\Libresign\Helper\MagicGetterSetterTrait;
17
use OCA\Libresign\Service\CaIdentifierService;
18
use OCA\Libresign\Service\CertificatePolicyService;
19
use OCP\Files\AppData\IAppDataFactory;
20
use OCP\Files\IAppData;
21
use OCP\Files\SimpleFS\ISimpleFolder;
22
use OCP\IAppConfig;
23
use OCP\IConfig;
24
use OCP\IDateTimeFormatter;
25
use OCP\ITempManager;
26
use OCP\IURLGenerator;
27
use OpenSSLAsymmetricKey;
28
use OpenSSLCertificate;
29
use Psr\Log\LoggerInterface;
30
use ReflectionClass;
31

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

58
        protected string $commonName = '';
59
        protected array $hosts = [];
60
        protected string $friendlyName = '';
61
        protected string $country = '';
62
        protected string $state = '';
63
        protected string $locality = '';
64
        protected string $organization = '';
65
        protected array $organizationalUnit = [];
66
        protected string $UID = '';
67
        protected string $password = '';
68
        protected string $configPath = '';
69
        protected string $engine = '';
70
        protected string $certificate = '';
71
        protected string $currentCaId = '';
72
        protected IAppData $appData;
73

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

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

112
                return $certContent;
8✔
113
        }
114

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

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

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

143
        public function getCaId(): string {
144
                $caId = $this->caIdentifierService->getCaId();
9✔
145
                if (empty($caId)) {
9✔
146
                        $caId = $this->caIdentifierService->generateCaId($this->getName());
2✔
147
                }
148
                return $caId;
9✔
149
        }
150

151
        #[\Override]
152
        public function parseCertificate(string $certificate): array {
153
                return $this->parseX509($certificate);
2✔
154
        }
155

156
        private function parseX509(string $x509): array {
157
                $parsed = openssl_x509_parse(openssl_x509_read($x509));
9✔
158

159
                $return = self::convertArrayToUtf8($parsed);
9✔
160

161
                foreach (['subject', 'issuer'] as $actor) {
9✔
162
                        foreach ($return[$actor] as $part => $value) {
9✔
163
                                if (is_string($value) && str_contains($value, ', ')) {
9✔
164
                                        $return[$actor][$part] = explode(', ', $value);
2✔
165
                                } else {
166
                                        $return[$actor][$part] = $value;
9✔
167
                                }
168
                        }
169
                }
170

171
                $return['valid_from'] = $this->dateTimeFormatter->formatDateTime($parsed['validFrom_time_t']);
9✔
172
                $return['valid_to'] = $this->dateTimeFormatter->formatDateTime($parsed['validTo_time_t']);
9✔
173

174
                $this->addCrlValidationInfo($return, $x509);
9✔
175

176
                return $return;
9✔
177
        }
178

179
        private function addCrlValidationInfo(array &$certData, string $certPem): void {
180
                if (isset($certData['extensions']['crlDistributionPoints'])) {
9✔
181
                        $crlDistributionPoints = $certData['extensions']['crlDistributionPoints'];
7✔
182

183
                        preg_match_all('/URI:([^\s,\n]+)/', $crlDistributionPoints, $matches);
7✔
184
                        $extractedUrls = $matches[1] ?? [];
7✔
185

186
                        $certData['crl_urls'] = $extractedUrls;
7✔
187
                        $certData['crl_validation'] = $this->validateCrlFromUrls($extractedUrls, $certPem);
7✔
188
                } else {
189
                        $certData['crl_validation'] = 'missing';
2✔
190
                        $certData['crl_urls'] = [];
2✔
191
                }
192
        }
193

194
        private static function convertArrayToUtf8($array) {
195
                foreach ($array as $key => $value) {
9✔
196
                        if (is_array($value)) {
9✔
197
                                $array[$key] = self::convertArrayToUtf8($value);
9✔
198
                        } elseif (is_string($value)) {
9✔
199
                                $array[$key] = mb_convert_encoding($value, 'UTF-8', 'UTF-8');
9✔
200
                        }
201
                }
202
                return $array;
9✔
203
        }
204

205
        public function opensslPkcs12Read(string &$certificate, string $privateKey): array {
206
                openssl_pkcs12_read($certificate, $certContent, $privateKey);
9✔
207
                if (!empty($certContent)) {
9✔
208
                        return $certContent;
7✔
209
                }
210
                /**
211
                 * Reference:
212
                 *
213
                 * https://github.com/php/php-src/issues/12128
214
                 * https://www.php.net/manual/en/function.openssl-pkcs12-read.php#128992
215
                 */
216
                $msg = openssl_error_string();
2✔
217
                if ($msg === 'error:0308010C:digital envelope routines::unsupported') {
2✔
218
                        $tempPassword = $this->tempManager->getTemporaryFile();
×
219
                        $tempEncriptedOriginal = $this->tempManager->getTemporaryFile();
×
220
                        $tempEncriptedRepacked = $this->tempManager->getTemporaryFile();
×
221
                        $tempDecrypted = $this->tempManager->getTemporaryFile();
×
222
                        file_put_contents($tempPassword, $privateKey);
×
223
                        file_put_contents($tempEncriptedOriginal, $certificate);
×
224
                        shell_exec(<<<REPACK_COMMAND
×
225
                                cat $tempPassword | openssl pkcs12 -legacy -in $tempEncriptedOriginal -nodes -out $tempDecrypted -passin stdin &&
×
226
                                cat $tempPassword | openssl pkcs12 -in $tempDecrypted -export -out $tempEncriptedRepacked -passout stdin
×
227
                                REPACK_COMMAND
×
228
                        );
×
229
                        $certificateRepacked = file_get_contents($tempEncriptedRepacked);
×
230
                        openssl_pkcs12_read($certificateRepacked, $certContent, $privateKey);
×
231
                        if (!empty($certContent)) {
×
232
                                $certificate = $certificateRepacked;
×
233
                                return $certContent;
×
234
                        }
235
                }
236
                throw new InvalidPasswordException();
2✔
237
        }
238

239
        /**
240
         * @param (int|string) $name
241
         *
242
         * @psalm-param array-key $name
243
         */
244
        public function translateToLong($name): string {
245
                return match ($name) {
3✔
246
                        'CN' => 'CommonName',
×
247
                        'C' => 'Country',
3✔
248
                        'ST' => 'State',
×
249
                        'L' => 'Locality',
×
250
                        'O' => 'Organization',
3✔
251
                        'OU' => 'OrganizationalUnit',
2✔
252
                        'UID' => 'UserIdentifier',
×
253
                        default => '',
3✔
254
                };
3✔
255
        }
256

257
        public function setEngine(string $engine): void {
258
                $this->appConfig->setValueString(Application::APP_ID, 'certificate_engine', $engine);
×
259
                $this->engine = $engine;
×
260
        }
261

262
        #[\Override]
263
        public function getEngine(): string {
264
                if ($this->engine) {
×
265
                        return $this->engine;
×
266
                }
267
                $this->engine = $this->appConfig->getValueString(Application::APP_ID, 'certificate_engine', 'openssl');
×
268
                return $this->engine;
×
269
        }
270

271
        #[\Override]
272
        public function populateInstance(array $rootCert): IEngineHandler {
273
                if (empty($rootCert)) {
20✔
274
                        $rootCert = $this->appConfig->getValueArray(Application::APP_ID, 'rootCert');
20✔
275
                }
276
                if (!$rootCert) {
20✔
277
                        return $this;
20✔
278
                }
279
                if (!empty($rootCert['names'])) {
×
280
                        foreach ($rootCert['names'] as $id => $customName) {
×
281
                                $longCustomName = $this->translateToLong($id);
×
282
                                // Prevent to save a property that don't exists
283
                                if (!property_exists($this, lcfirst($longCustomName))) {
×
284
                                        continue;
×
285
                                }
286
                                $this->{'set' . ucfirst($longCustomName)}($customName['value']);
×
287
                        }
288
                }
289
                if (!$this->getCommonName()) {
×
290
                        $this->setCommonName($rootCert['commonName']);
×
291
                }
292
                return $this;
×
293
        }
294

295
        #[\Override]
296
        public function getCurrentConfigPath(): string {
297
                if ($this->configPath) {
47✔
298
                        return $this->configPath;
46✔
299
                }
300

301
                $customConfigPath = $this->appConfig->getValueString(Application::APP_ID, 'config_path');
45✔
302
                if ($customConfigPath && is_dir($customConfigPath)) {
45✔
303
                        $this->configPath = $customConfigPath;
37✔
304
                        return $this->configPath;
37✔
305
                }
306

307
                $this->configPath = $this->initializePkiConfigPath();
9✔
308
                if (!empty($this->configPath)) {
9✔
309
                        $this->appConfig->setValueString(Application::APP_ID, 'config_path', $this->configPath);
9✔
310
                }
311
                return $this->configPath;
9✔
312
        }
313

314
        #[\Override]
315
        public function getConfigPathByParams(string $instanceId, int $generation): string {
316
                $engineName = $this->getName();
24✔
317

318
                $pkiDirName = $this->caIdentifierService->generatePkiDirectoryNameFromParams($instanceId, $generation, $engineName);
24✔
319
                $dataDir = $this->config->getSystemValue('datadirectory', \OC::$SERVERROOT . '/data/');
24✔
320
                $systemInstanceId = $this->config->getSystemValue('instanceid');
24✔
321
                $pkiPath = $dataDir . '/appdata_' . $systemInstanceId . '/libresign/' . $pkiDirName;
24✔
322

323
                if (!is_dir($pkiPath)) {
24✔
324
                        throw new \RuntimeException("Config path does not exist for instanceId: {$instanceId}, generation: {$generation}");
6✔
325
                }
326

327
                return $pkiPath;
19✔
328
        }
329

330
        private function initializePkiConfigPath(): string {
331
                $caId = $this->getCaId();
9✔
332
                if (empty($caId)) {
9✔
333
                        return '';
×
334
                }
335
                $pkiDirName = $this->caIdentifierService->generatePkiDirectoryName($caId);
9✔
336
                $dataDir = $this->config->getSystemValue('datadirectory', \OC::$SERVERROOT . '/data/');
9✔
337
                $instanceId = $this->config->getSystemValue('instanceid');
9✔
338
                $pkiPath = $dataDir . '/appdata_' . $instanceId . '/libresign/' . $pkiDirName;
9✔
339

340
                if (!is_dir($pkiPath)) {
9✔
341
                        $this->createDirectoryWithCorrectOwnership($pkiPath);
9✔
342
                }
343

344
                return $pkiPath;
9✔
345
        }
346

347
        private function createDirectoryWithCorrectOwnership(string $path): void {
348
                $ownerInfo = $this->getFilesOwnerInfo();
9✔
349
                $fullCommand = 'mkdir -p ' . escapeshellarg($path);
9✔
350

351
                if (posix_getuid() !== $ownerInfo['uid']) {
9✔
352
                        $fullCommand = 'runuser -u ' . $ownerInfo['name'] . ' -- ' . $fullCommand;
×
353
                }
354

355
                exec($fullCommand);
9✔
356
        }
357

358
        private function getFilesOwnerInfo(): array {
359
                $currentFile = realpath(__DIR__);
9✔
360
                $owner = fileowner($currentFile);
9✔
361
                if ($owner === false) {
9✔
362
                        throw new \RuntimeException('Unable to get file information');
×
363
                }
364
                $ownerInfo = posix_getpwuid($owner);
9✔
365
                if ($ownerInfo === false) {
9✔
366
                        throw new \RuntimeException('Unable to get file owner information');
×
367
                }
368

369
                return $ownerInfo;
9✔
370
        }
371

372
        /**
373
         * @todo check a best solution to don't use reflection
374
         */
375
        private function getInternalPathOfFolder(ISimpleFolder $node): string {
376
                $reflection = new \ReflectionClass($node);
×
377
                $reflectionProperty = $reflection->getProperty('folder');
×
378
                $folder = $reflectionProperty->getValue($node);
×
379
                $path = $folder->getInternalPath();
×
380
                return $path;
×
381
        }
382

383
        #[\Override]
384
        public function setConfigPath(string $configPath): IEngineHandler {
385
                if (!$configPath) {
×
386
                        $this->appConfig->deleteKey(Application::APP_ID, 'config_path');
×
387
                } else {
388
                        if (!is_dir($configPath)) {
×
389
                                mkdir(
×
390
                                        directory: $configPath,
×
391
                                        recursive: true,
×
392
                                );
×
393
                        }
394
                        $this->appConfig->setValueString(Application::APP_ID, 'config_path', $configPath);
×
395
                }
396
                $this->configPath = $configPath;
×
397
                return $this;
×
398
        }
399

400
        public function getName(): string {
401
                $reflect = new ReflectionClass($this);
26✔
402
                $className = $reflect->getShortName();
26✔
403
                $name = strtolower(substr($className, 0, -7));
26✔
404
                return $name;
26✔
405
        }
406

407
        protected function getNames(): array {
408
                $names = [
43✔
409
                        'C' => $this->getCountry(),
43✔
410
                        'ST' => $this->getState(),
43✔
411
                        'L' => $this->getLocality(),
43✔
412
                        'O' => $this->getOrganization(),
43✔
413
                        'OU' => $this->getOrganizationalUnit(),
43✔
414
                ];
43✔
415
                if ($uid = $this->getUID()) {
43✔
416
                        $names['UID'] = $uid;
×
417
                }
418
                $names = array_filter($names, fn ($v) => !empty($v));
43✔
419
                return $names;
43✔
420
        }
421

422
        public function getUID(): string {
423
                return str_replace(' ', '+', $this->UID);
43✔
424
        }
425

426
        #[\Override]
427
        public function getLeafExpiryInDays(): int {
428
                $exp = $this->appConfig->getValueInt(Application::APP_ID, 'expiry_in_days', 365);
19✔
429
                return $exp > 0 ? $exp : 365;
19✔
430
        }
431

432
        #[\Override]
433
        public function getCaExpiryInDays(): int {
434
                $exp = $this->appConfig->getValueInt(Application::APP_ID, 'ca_expiry_in_days', 3650); // 10 years
42✔
435
                return $exp > 0 ? $exp : 3650;
42✔
436
        }
437

438
        private function getCertificatePolicy(): array {
439
                $return = ['policySection' => []];
1✔
440
                $oid = $this->certificatePolicyService->getOid();
1✔
441
                $cps = $this->certificatePolicyService->getCps();
1✔
442
                if ($oid && $cps) {
1✔
443
                        $return['policySection'][] = [
×
444
                                'OID' => $oid,
×
445
                                'CPS' => $cps,
×
446
                        ];
×
447
                }
448
                return $return;
1✔
449
        }
450

451
        abstract protected function getConfigureCheckResourceName(): string;
452

453
        abstract protected function getCertificateRegenerationTip(): string;
454

455
        abstract protected function getEngineSpecificChecks(): array;
456

457
        abstract protected function getSetupSuccessMessage(): string;
458

459
        abstract protected function getSetupErrorMessage(): string;
460

461
        abstract protected function getSetupErrorTip(): string;
462

463
        #[\Override]
464
        public function configureCheck(): array {
465
                $checks = $this->getEngineSpecificChecks();
1✔
466

467
                if (!$this->isSetupOk()) {
1✔
468
                        return array_merge($checks, [
1✔
469
                                (new ConfigureCheckHelper())
1✔
470
                                        ->setErrorMessage($this->getSetupErrorMessage())
1✔
471
                                        ->setResource($this->getConfigureCheckResourceName())
1✔
472
                                        ->setTip($this->getSetupErrorTip())
1✔
473
                        ]);
1✔
474
                }
475

476
                $checks[] = (new ConfigureCheckHelper())
×
477
                        ->setSuccessMessage($this->getSetupSuccessMessage())
×
478
                        ->setResource($this->getConfigureCheckResourceName());
×
479

480
                $modernFeaturesCheck = $this->checkRootCertificateModernFeatures();
×
481
                if ($modernFeaturesCheck) {
×
482
                        $checks[] = $modernFeaturesCheck;
×
483
                }
484

NEW
485
                return $checks;
×
486
        }
487

488
        protected function checkRootCertificateModernFeatures(): ?ConfigureCheckHelper {
NEW
489
                $configPath = $this->getCurrentConfigPath();
×
490
                $caCertPath = $configPath . DIRECTORY_SEPARATOR . 'ca.pem';
×
491

492
                try {
493
                        $certContent = file_get_contents($caCertPath);
×
494
                        if (!$certContent) {
×
495
                                return (new ConfigureCheckHelper())
×
496
                                        ->setErrorMessage('Failed to read root certificate file')
×
497
                                        ->setResource($this->getConfigureCheckResourceName())
×
498
                                        ->setTip('Check file permissions and disk space');
×
499
                        }
500

501
                        $x509Resource = openssl_x509_read($certContent);
×
502
                        if (!$x509Resource) {
×
503
                                return (new ConfigureCheckHelper())
×
504
                                        ->setErrorMessage('Failed to parse root certificate')
×
505
                                        ->setResource($this->getConfigureCheckResourceName())
×
506
                                        ->setTip('Root certificate file may be corrupted or invalid');
×
507
                        }
508

509
                        $parsed = openssl_x509_parse($x509Resource);
×
510
                        if (!$parsed) {
×
511
                                return (new ConfigureCheckHelper())
×
512
                                        ->setErrorMessage('Failed to extract root certificate information')
×
513
                                        ->setResource($this->getConfigureCheckResourceName())
×
514
                                        ->setTip('Root certificate may be in an unsupported format');
×
515
                        }
516

517
                        $criticalIssues = [];
×
518
                        $minorIssues = [];
×
519

520
                        if (isset($parsed['serialNumber'])) {
×
521
                                $serialNumber = $parsed['serialNumber'];
×
522
                                $serialDecimal = hexdec($serialNumber);
×
523
                                if ($serialDecimal <= 1) {
×
524
                                        $minorIssues[] = 'Serial number is simple (zero or one)';
×
525
                                }
526
                        } else {
527
                                $criticalIssues[] = 'Serial number is missing';
×
528
                        }
529

530
                        $missingExtensions = [];
×
531
                        if (!isset($parsed['extensions']['subjectKeyIdentifier'])) {
×
532
                                $missingExtensions[] = 'Subject Key Identifier (SKI)';
×
533
                        }
534

535
                        $isSelfSigned = (isset($parsed['issuer']) && isset($parsed['subject'])
×
536
                                                        && $parsed['issuer'] === $parsed['subject']);
×
537

538
                        /**
539
                         * @todo workarround for missing AKI at certificates generated by CFSSL.
540
                         *
541
                         * CFSSL does not add Authority Key Identifier (AKI) to self-signed root certificates.
542
                         */
543
                        if (!$isSelfSigned && !isset($parsed['extensions']['authorityKeyIdentifier'])) {
×
544
                                $missingExtensions[] = 'Authority Key Identifier (AKI)';
×
545
                        }
546

547
                        if (!isset($parsed['extensions']['crlDistributionPoints'])) {
×
548
                                $missingExtensions[] = 'CRL Distribution Points';
×
549
                        }
550

551
                        if (!empty($missingExtensions)) {
×
552
                                $extensionsList = implode(', ', $missingExtensions);
×
553
                                $minorIssues[] = "Missing modern extensions: {$extensionsList}";
×
554
                        }
555

556
                        $hasLibresignCaUuid = $this->validateLibresignCaUuidInCertificate($parsed);
×
557
                        if (!$hasLibresignCaUuid) {
×
558
                                $minorIssues[] = 'LibreSign CA UUID not found in Organizational Unit';
×
559
                        }
560

561
                        if (!empty($criticalIssues)) {
×
562
                                $issuesList = implode(', ', $criticalIssues);
×
563
                                return (new ConfigureCheckHelper())
×
564
                                        ->setErrorMessage("Root certificate has critical issues: {$issuesList}")
×
565
                                        ->setResource($this->getConfigureCheckResourceName())
×
566
                                        ->setTip($this->getCertificateRegenerationTip());
×
567
                        }
568

569
                        if (!empty($minorIssues)) {
×
570
                                $issuesList = implode(', ', $minorIssues);
×
571
                                return (new ConfigureCheckHelper())
×
572
                                        ->setInfoMessage("Root certificate could benefit from modern features: {$issuesList}")
×
573
                                        ->setResource($this->getConfigureCheckResourceName())
×
574
                                        ->setTip($this->getCertificateRegenerationTip() . ' (recommended but not required)');
×
575
                        }
576

577
                        return null;
×
578

579
                } catch (\Exception $e) {
×
580
                        return (new ConfigureCheckHelper())
×
581
                                ->setErrorMessage('Failed to analyze root certificate: ' . $e->getMessage())
×
582
                                ->setResource($this->getConfigureCheckResourceName())
×
583
                                ->setTip('Check if the root certificate file is valid');
×
584
                }
585
        }
586

587
        private function validateLibresignCaUuidInCertificate(array $parsed): bool {
588
                if (!isset($parsed['subject']['OU'])) {
×
589
                        return false;
×
590
                }
591

592
                $instanceId = $this->getLibreSignInstanceId();
×
593
                if (empty($instanceId)) {
×
594
                        return false;
×
595
                }
596

597
                $organizationalUnits = $parsed['subject']['OU'];
×
598

599
                if (is_string($organizationalUnits)) {
×
600
                        if (str_contains($organizationalUnits, ', ')) {
×
601
                                $organizationalUnits = explode(', ', $organizationalUnits);
×
602
                        } else {
603
                                $organizationalUnits = [$organizationalUnits];
×
604
                        }
605
                }
606

607
                foreach ($organizationalUnits as $ou) {
×
608
                        $ou = trim($ou);
×
609
                        if ($this->caIdentifierService->isValidCaId($ou, $instanceId)) {
×
610
                                return true;
×
611
                        }
612
                }
613

614
                return false;
×
615
        }
616

617
        private function getLibreSignInstanceId(): string {
618
                $instanceId = $this->appConfig->getValueString(Application::APP_ID, 'instance_id', '');
×
619
                if (strlen($instanceId) === 10) {
×
620
                        return $instanceId;
×
621
                }
622
                return '';
×
623
        }
624

625
        #[\Override]
626
        public function toArray(): array {
627
                $return = [
1✔
628
                        'configPath' => $this->getCurrentConfigPath(),
1✔
629
                        'generated' => $this->isSetupOk(),
1✔
630
                        'rootCert' => [
1✔
631
                                'commonName' => $this->getCommonName(),
1✔
632
                                'names' => [],
1✔
633
                        ],
1✔
634
                ];
1✔
635
                $return = array_merge(
1✔
636
                        $return,
1✔
637
                        $this->getCertificatePolicy(),
1✔
638
                );
1✔
639
                $names = $this->getNames();
1✔
640
                foreach ($names as $name => $value) {
1✔
NEW
641
                        $return['rootCert']['names'][] = [
×
NEW
642
                                'id' => $name,
×
NEW
643
                                'value' => $value,
×
NEW
644
                        ];
×
645
                }
646
                return $return;
1✔
647
        }
648

649
        protected function getCrlDistributionUrl(): string {
650
                $caIdParsed = $this->caIdentifierService->getCaIdParsed();
42✔
651
                return $this->urlGenerator->linkToRouteAbsolute('libresign.crl.getRevocationList', [
42✔
652
                        'instanceId' => $caIdParsed['instanceId'],
42✔
653
                        'generation' => $caIdParsed['generation'],
42✔
654
                        'engineType' => $caIdParsed['engineType'],
42✔
655
                ]);
42✔
656
        }
657

658
        private function validateCrlFromUrls(array $crlUrls, string $certPem): string {
659
                if (empty($crlUrls)) {
7✔
NEW
660
                        return 'no_urls';
×
661
                }
662

663
                $accessibleUrls = 0;
7✔
664
                foreach ($crlUrls as $crlUrl) {
7✔
665
                        try {
666
                                $validationResult = $this->downloadAndValidateCrl($crlUrl, $certPem);
7✔
667
                                if ($validationResult === 'valid') {
7✔
668
                                        return 'valid';
1✔
669
                                }
670
                                if ($validationResult === 'revoked') {
6✔
NEW
671
                                        return 'revoked';
×
672
                                }
673
                                $accessibleUrls++;
6✔
NEW
674
                        } catch (\Exception $e) {
×
NEW
675
                                continue;
×
676
                        }
677
                }
678

679
                if ($accessibleUrls === 0) {
6✔
NEW
680
                        return 'urls_inaccessible';
×
681
                }
682

683
                return 'validation_failed';
6✔
684
        }
685

686
        private function downloadAndValidateCrl(string $crlUrl, string $certPem): string {
687
                try {
688
                        if ($this->isLocalCrlUrl($crlUrl)) {
7✔
689
                                $crlContent = $this->generateLocalCrl($crlUrl);
7✔
690
                        } else {
NEW
691
                                $crlContent = $this->downloadCrlContent($crlUrl);
×
692
                        }
693

694
                        if (!$crlContent) {
7✔
695
                                throw new \Exception('Failed to get CRL content');
6✔
696
                        }
697

698
                        return $this->checkCertificateInCrl($certPem, $crlContent);
1✔
699

700
                } catch (\Exception $e) {
6✔
701
                        return 'validation_error';
6✔
702
                }
703
        }
704

705
        private function isLocalCrlUrl(string $url): bool {
706
                $host = parse_url($url, PHP_URL_HOST);
7✔
707
                if (!$host) {
7✔
708
                        return false;
×
709
                }
710

711
                $trustedDomains = $this->config->getSystemValue('trusted_domains', []);
7✔
712

713
                return in_array($host, $trustedDomains, true);
7✔
714
        }
715

716
        private function generateLocalCrl(string $crlUrl): ?string {
717
                try {
718
                        $templateUrl = $this->urlGenerator->linkToRouteAbsolute('libresign.crl.getRevocationList', [
7✔
719
                                'instanceId' => 'INSTANCEID',
7✔
720
                                'generation' => 999999,
7✔
721
                                'engineType' => 'ENGINETYPE',
7✔
722
                        ]);
7✔
723

724
                        $patternUrl = str_replace('INSTANCEID', '([^/_]+)', $templateUrl);
7✔
725
                        $patternUrl = str_replace('999999', '(\d+)', $patternUrl);
7✔
726
                        $patternUrl = str_replace('ENGINETYPE', '([^/_]+)', $patternUrl);
7✔
727

728
                        $escapedPattern = str_replace([':', '/', '.'], ['\:', '\/', '\.'], $patternUrl);
7✔
729

730
                        $escapedPattern = str_replace('\/apps\/', '(?:\/index\.php)?\/apps\/', $escapedPattern);
7✔
731

732
                        $pattern = '/^' . $escapedPattern . '$/';
7✔
733
                        if (preg_match($pattern, $crlUrl, $matches)) {
7✔
734
                                $instanceId = $matches[1];
7✔
735
                                $generation = (int)$matches[2];
7✔
736
                                $engineType = $matches[3];
7✔
737

738
                                /** @var \OCA\Libresign\Service\CrlService */
739
                                $crlService = \OC::$server->get(\OCA\Libresign\Service\CrlService::class);
7✔
740

741
                                $crlData = $crlService->generateCrlDer($instanceId, $generation, $engineType);
7✔
742

743
                                return $crlData;
1✔
744
                        }
745

746
                        $this->logger->debug('CRL URL does not match expected pattern', ['url' => $crlUrl, 'pattern' => $pattern]);
×
747
                        return null;
×
748

749
                } catch (\Exception $e) {
6✔
750
                        $this->logger->warning('Failed to generate local CRL: ' . $e->getMessage());
6✔
751
                        return null;
6✔
752
                }
753
        }
754

755
        private function downloadCrlContent(string $url): ?string {
756
                if (!filter_var($url, FILTER_VALIDATE_URL) || !in_array(parse_url($url, PHP_URL_SCHEME), ['http', 'https'])) {
×
757
                        return null;
×
758
                }
759

760
                $context = stream_context_create([
×
761
                        'http' => [
×
762
                                'timeout' => 30,
×
763
                                'user_agent' => 'LibreSign/1.0 CRL Validator',
×
764
                                'follow_location' => 1,
×
765
                                'max_redirects' => 3,
×
766
                        ]
×
767
                ]);
×
768

769
                $content = @file_get_contents($url, false, $context);
×
770
                return $content !== false ? $content : null;
×
771
        }
772

773
        private function isSerialNumberInCrl(string $crlText, string $serialNumber): bool {
774
                $normalizedSerial = strtoupper($serialNumber);
1✔
775
                $normalizedSerial = ltrim($normalizedSerial, '0') ?: '0';
1✔
776

777
                return preg_match('/Serial Number: 0*' . preg_quote($normalizedSerial, '/') . '/', $crlText) === 1;
1✔
778
        }
779

780
        private function checkCertificateInCrl(string $certPem, string $crlContent): string {
781
                try {
782
                        $certResource = openssl_x509_read($certPem);
1✔
783
                        if (!$certResource) {
1✔
784
                                return 'validation_error';
×
785
                        }
786

787
                        $certData = openssl_x509_parse($certResource);
1✔
788
                        if (!isset($certData['serialNumber'])) {
1✔
789
                                return 'validation_error';
×
790
                        }
791

792
                        $tempCrlFile = $this->tempManager->getTemporaryFile('.crl');
1✔
793
                        file_put_contents($tempCrlFile, $crlContent);
1✔
794

795
                        try {
796
                                $crlTextCmd = sprintf(
1✔
797
                                        'openssl crl -in %s -inform DER -text -noout',
1✔
798
                                        escapeshellarg($tempCrlFile)
1✔
799
                                );
1✔
800

801
                                exec($crlTextCmd, $output, $exitCode);
1✔
802

803
                                if ($exitCode === 0) {
1✔
804
                                        $crlText = implode("\n", $output);
1✔
805

806
                                        if ($this->isSerialNumberInCrl($crlText, $certData['serialNumber'])
1✔
807
                                                || (!empty($certData['serialNumberHex']) && $this->isSerialNumberInCrl($crlText, $certData['serialNumberHex']))) {
1✔
808
                                                return 'revoked';
×
809
                                        }
810

811
                                        return 'valid';
1✔
812
                                }
813

814
                                return 'validation_error';
×
815

816
                        } finally {
817
                                if (file_exists($tempCrlFile)) {
1✔
818
                                        unlink($tempCrlFile);
1✔
819
                                }
820
                        }
821

822
                } catch (\Exception $e) {
×
823
                        return 'validation_error';
×
824
                }
825
        }
826

827
        #[\Override]
828
        public function generateCrlDer(array $revokedCertificates, string $instanceId, int $generation, int $crlNumber): string {
829
                $configPath = $this->getConfigPathByParams($instanceId, $generation);
24✔
830
                $issuer = $this->loadCaIssuer($configPath);
19✔
831
                $signedCrl = $this->createAndSignCrl($issuer, $revokedCertificates, $crlNumber);
19✔
832
                $crlDerData = $this->saveCrlToDer($signedCrl, $configPath);
19✔
833

834
                return $crlDerData;
19✔
835
        }
836

837
        private function loadCaIssuer(string $configPath): \phpseclib3\File\X509 {
838
                $caCertPath = $configPath . DIRECTORY_SEPARATOR . 'ca.pem';
19✔
839
                $caKeyPath = $configPath . DIRECTORY_SEPARATOR . 'ca-key.pem';
19✔
840

841
                if (!file_exists($caCertPath) || !file_exists($caKeyPath)) {
19✔
842
                        $this->logger->error('CA certificate or private key not found', ['caCertPath' => $caCertPath, 'caKeyPath' => $caKeyPath]);
×
843
                        throw new \RuntimeException('CA certificate or private key not found. Run: occ libresign:configure:openssl');
×
844
                }
845

846
                $caCert = file_get_contents($caCertPath);
19✔
847
                $caKey = file_get_contents($caKeyPath);
19✔
848

849
                if (!$caCert || !$caKey) {
19✔
850
                        $this->logger->error('Failed to read CA certificate or private key', ['caCertPath' => $caCertPath, 'caKeyPath' => $caKeyPath]);
×
851
                        throw new \RuntimeException('Failed to read CA certificate or private key');
×
852
                }
853

854
                $issuer = new \phpseclib3\File\X509();
19✔
855
                $issuer->loadX509($caCert);
19✔
856
                $caPrivateKey = \phpseclib3\Crypt\PublicKeyLoader::load($caKey);
19✔
857

858
                if (!$caPrivateKey instanceof \phpseclib3\Crypt\Common\PrivateKey) {
19✔
859
                        $this->logger->error('Loaded key is not a private key', ['keyType' => get_class($caPrivateKey)]);
×
860
                        throw new \RuntimeException('Loaded key is not a private key');
×
861
                }
862

863
                $issuer->setPrivateKey($caPrivateKey);
19✔
864
                return $issuer;
19✔
865
        }
866

867
        private function createAndSignCrl(\phpseclib3\File\X509 $issuer, array $revokedCertificates, int $crlNumber): array {
868
                $utcZone = new \DateTimeZone('UTC');
19✔
869
                $crlToSign = new \phpseclib3\File\X509();
19✔
870
                $crlToSign->setSerialNumber((string)$crlNumber, 10);
19✔
871
                $crlToSign->setStartDate(new \DateTime('now', $utcZone));
19✔
872
                $crlToSign->setEndDate(new \DateTime('+7 days', $utcZone));
19✔
873

874
                $initialCrl = $crlToSign->signCRL($issuer, $crlToSign);
19✔
875
                if ($initialCrl === false) {
19✔
876
                        $this->logger->error('Failed to create initial CRL structure');
×
877
                        throw new \RuntimeException('Failed to create initial CRL structure');
×
878
                }
879

880
                if (!empty($revokedCertificates)) {
19✔
881
                        $savedCrl = $crlToSign->saveCRL($initialCrl);
17✔
882
                        if ($savedCrl === false) {
17✔
883
                                $this->logger->error('Failed to save initial CRL structure');
×
884
                                throw new \RuntimeException('Failed to save initial CRL structure');
×
885
                        }
886

887
                        $crlToSign->loadCRL($savedCrl);
17✔
888

889
                        $dateFormat = 'D, d M Y H:i:s O';
17✔
890
                        foreach ($revokedCertificates as $cert) {
17✔
891
                                $serialNumber = $cert->getSerialNumber();
17✔
892
                                $normalizedSerial = ltrim($serialNumber, '0') ?: '0';
17✔
893
                                $crlToSign->revoke(
17✔
894
                                        new \phpseclib3\Math\BigInteger($normalizedSerial, 16),
17✔
895
                                        $cert->getRevokedAt()->format($dateFormat)
17✔
896
                                );
17✔
897
                        }
898

899
                        $signedCrl = $crlToSign->signCRL($issuer, $crlToSign);
17✔
900
                } else {
901
                        $signedCrl = $initialCrl;
2✔
902
                }
903

904
                if ($signedCrl === false) {
19✔
905
                        $this->logger->error('Failed to sign CRL', ['crlNumber' => $crlNumber]);
×
906
                        throw new \RuntimeException('Failed to sign CRL');
×
907
                }
908

909
                if (!isset($signedCrl['signatureAlgorithm'])) {
19✔
910
                        $signedCrl['signatureAlgorithm'] = ['algorithm' => 'sha256WithRSAEncryption'];
×
911
                }
912

913
                return $signedCrl;
19✔
914
        }
915

916
        private function saveCrlToDer(array $signedCrl, string $configPath): string {
917
                $crlDerPath = $configPath . DIRECTORY_SEPARATOR . 'crl.der';
19✔
918
                $crlToSign = new \phpseclib3\File\X509();
19✔
919

920
                $crlDerData = $crlToSign->saveCRL($signedCrl, \phpseclib3\File\X509::FORMAT_DER);
19✔
921

922
                if ($crlDerData === false) {
19✔
923
                        $this->logger->error('Failed to save CRL in DER format');
×
924
                        throw new \RuntimeException('Failed to save CRL in DER format');
×
925
                }
926

927
                if (file_put_contents($crlDerPath, $crlDerData) === false) {
19✔
928
                        $this->logger->error('Failed to write CRL DER file', ['path' => $crlDerPath]);
×
929
                        throw new \RuntimeException('Failed to write CRL DER file');
×
930
                }
931

932
                return $crlDerData;
19✔
933
        }
934

935
        #[\Override]
936
        public function validateRootCertificate(): void {
937
                $configPath = $this->getCurrentConfigPath();
22✔
938
                if (empty($configPath)) {
22✔
939
                        return;
×
940
                }
941

942
                if (!is_dir($configPath)) {
22✔
943
                        return;
×
944
                }
945

946
                $rootCertPath = $configPath . DIRECTORY_SEPARATOR . 'ca.pem';
22✔
947

948
                if (!file_exists($rootCertPath)) {
22✔
949
                        return;
3✔
950
                }
951

952
                $rootCert = file_get_contents($rootCertPath);
19✔
953
                if (empty($rootCert)) {
19✔
954
                        return;
×
955
                }
956

957
                $certificate = openssl_x509_read($rootCert);
19✔
958
                if ($certificate === false) {
19✔
959
                        throw new LibresignException('Invalid root certificate content');
×
960
                }
961
                $certInfo = openssl_x509_parse($certificate);
19✔
962
                if ($certInfo === false) {
19✔
963
                        throw new LibresignException('Failed to parse root certificate');
×
964
                }
965

966
                if ($this->checkCertificateRevoked($certInfo['serialNumber'])) {
19✔
967
                        $this->logger->error('Root certificate has been revoked', [
×
968
                                'ca_id' => $this->getCaId(),
×
969
                                'impact' => 'all_leaf_certificates_invalid',
×
970
                        ]);
×
971
                        throw new LibresignException(
×
972
                                'Root certificate has been revoked. Please regenerate your signing certificate.',
×
973
                                \OC\AppFramework\Http::STATUS_PRECONDITION_FAILED
×
974
                        );
×
975
                }
976

977
                if ($certInfo['validTo_time_t'] < time()) {
19✔
978
                        $this->logger->error('Root certificate has expired', [
×
979
                                'ca_id' => $this->getCaId(),
×
980
                        ]);
×
981
                        throw new LibresignException(
×
982
                                'Root certificate expired. Please regenerate your signing certificate.',
×
983
                                \OC\AppFramework\Http::STATUS_PRECONDITION_FAILED
×
984
                        );
×
985
                }
986

987
                $secondsPerDay = 60 * 60 * 24;
19✔
988
                $remainingDays = (int)ceil(($certInfo['validTo_time_t'] - time()) / $secondsPerDay);
19✔
989
                $leafExpiryDays = $this->getLeafExpiryInDays();
19✔
990

991
                if ($remainingDays <= $leafExpiryDays) {
19✔
992
                        $this->logger->warning('Root certificate renewal needed', [
12✔
993
                                'remaining_days' => $remainingDays,
12✔
994
                                'leaf_expiry_days' => $leafExpiryDays,
12✔
995
                        ]);
12✔
996
                }
997
        }
998

999
        private function checkCertificateRevoked(string $serialNumber): bool {
1000
                try {
1001
                        /** @var \OCA\Libresign\Service\CrlService */
1002
                        $crlService = \OC::$server->get(\OCA\Libresign\Service\CrlService::class);
19✔
1003
                        $status = $crlService->getCertificateStatus($serialNumber);
19✔
1004
                        return $status['status'] === 'revoked';
19✔
NEW
1005
                } catch (\Exception $e) {
×
NEW
1006
                        $this->logger->warning('Failed to check root certificate revocation status', [
×
NEW
1007
                                'error' => $e->getMessage()
×
NEW
1008
                        ]);
×
NEW
1009
                        return false;
×
1010
                }
1011
        }
1012
}
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