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

LibreSign / libresign / 19835008570

01 Dec 2025 07:33PM UTC coverage: 40.733%. First build
19835008570

Pull #5863

github

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

63 of 98 new or added lines in 7 files covered. (64.29%)

4932 of 12108 relevant lines covered (40.73%)

3.9 hits per line

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

65.12
/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');
100✔
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) {
54✔
298
                        return $this->configPath;
53✔
299
                }
300

301
                $customConfigPath = $this->appConfig->getValueString(Application::APP_ID, 'config_path');
52✔
302
                if ($customConfigPath && is_dir($customConfigPath)) {
52✔
303
                        $this->configPath = $customConfigPath;
44✔
304
                        return $this->configPath;
44✔
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 = [
50✔
409
                        'C' => $this->getCountry(),
50✔
410
                        'ST' => $this->getState(),
50✔
411
                        'L' => $this->getLocality(),
50✔
412
                        'O' => $this->getOrganization(),
50✔
413
                        'OU' => $this->getOrganizationalUnit(),
50✔
414
                ];
50✔
415
                if ($uid = $this->getUID()) {
50✔
416
                        $names['UID'] = $uid;
×
417
                }
418
                $names = array_filter($names, fn ($v) => !empty($v));
50✔
419
                return $names;
50✔
420
        }
421

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

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

432
        #[\Override]
433
        public function getCaExpiryInDays(): int {
434
                $exp = $this->appConfig->getValueInt(Application::APP_ID, 'ca_expiry_in_days', 3650); // 10 years
49✔
435
                return $exp > 0 ? $exp : 3650;
49✔
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();
8✔
466

467
                if (!$this->isSetupOk()) {
8✔
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())
7✔
477
                        ->setSuccessMessage($this->getSetupSuccessMessage())
7✔
478
                        ->setResource($this->getConfigureCheckResourceName());
7✔
479

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

485
                $expiryCheck = $this->checkRootCertificateExpiry();
7✔
486
                if ($expiryCheck) {
7✔
487
                        $checks[] = $expiryCheck;
5✔
488
                }
489

490
                return $checks;
7✔
491
        }
492

493
        protected function checkRootCertificateModernFeatures(): ?ConfigureCheckHelper {
494
                $configPath = $this->getCurrentConfigPath();
7✔
495
                $caCertPath = $configPath . DIRECTORY_SEPARATOR . 'ca.pem';
7✔
496

497
                try {
498
                        $certContent = file_get_contents($caCertPath);
7✔
499
                        if (!$certContent) {
7✔
500
                                return (new ConfigureCheckHelper())
×
501
                                        ->setErrorMessage('Failed to read root certificate file')
×
502
                                        ->setResource($this->getConfigureCheckResourceName())
×
503
                                        ->setTip('Check file permissions and disk space');
×
504
                        }
505

506
                        $x509Resource = openssl_x509_read($certContent);
7✔
507
                        if (!$x509Resource) {
7✔
508
                                return (new ConfigureCheckHelper())
×
509
                                        ->setErrorMessage('Failed to parse root certificate')
×
510
                                        ->setResource($this->getConfigureCheckResourceName())
×
511
                                        ->setTip('Root certificate file may be corrupted or invalid');
×
512
                        }
513

514
                        $parsed = openssl_x509_parse($x509Resource);
7✔
515
                        if (!$parsed) {
7✔
516
                                return (new ConfigureCheckHelper())
×
517
                                        ->setErrorMessage('Failed to extract root certificate information')
×
518
                                        ->setResource($this->getConfigureCheckResourceName())
×
519
                                        ->setTip('Root certificate may be in an unsupported format');
×
520
                        }
521

522
                        $criticalIssues = [];
7✔
523
                        $minorIssues = [];
7✔
524

525
                        if (isset($parsed['serialNumber'])) {
7✔
526
                                $serialNumber = $parsed['serialNumber'];
7✔
527
                                $serialDecimal = hexdec($serialNumber);
7✔
528
                                if ($serialDecimal <= 1) {
7✔
529
                                        $minorIssues[] = 'Serial number is simple (zero or one)';
×
530
                                }
531
                        } else {
532
                                $criticalIssues[] = 'Serial number is missing';
×
533
                        }
534

535
                        $missingExtensions = [];
7✔
536
                        if (!isset($parsed['extensions']['subjectKeyIdentifier'])) {
7✔
537
                                $missingExtensions[] = 'Subject Key Identifier (SKI)';
×
538
                        }
539

540
                        $isSelfSigned = (isset($parsed['issuer']) && isset($parsed['subject'])
7✔
541
                                                        && $parsed['issuer'] === $parsed['subject']);
7✔
542

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

552
                        if (!isset($parsed['extensions']['crlDistributionPoints'])) {
7✔
553
                                $missingExtensions[] = 'CRL Distribution Points';
×
554
                        }
555

556
                        if (!empty($missingExtensions)) {
7✔
557
                                $extensionsList = implode(', ', $missingExtensions);
×
558
                                $minorIssues[] = "Missing modern extensions: {$extensionsList}";
×
559
                        }
560

561
                        $hasLibresignCaUuid = $this->validateLibresignCaUuidInCertificate($parsed);
7✔
562
                        if (!$hasLibresignCaUuid) {
7✔
563
                                $minorIssues[] = 'LibreSign CA UUID not found in Organizational Unit';
7✔
564
                        }
565

566
                        if (!empty($criticalIssues)) {
7✔
567
                                $issuesList = implode(', ', $criticalIssues);
×
568
                                return (new ConfigureCheckHelper())
×
569
                                        ->setErrorMessage("Root certificate has critical issues: {$issuesList}")
×
570
                                        ->setResource($this->getConfigureCheckResourceName())
×
571
                                        ->setTip($this->getCertificateRegenerationTip());
×
572
                        }
573

574
                        if (!empty($minorIssues)) {
7✔
575
                                $issuesList = implode(', ', $minorIssues);
7✔
576
                                return (new ConfigureCheckHelper())
7✔
577
                                        ->setInfoMessage("Root certificate could benefit from modern features: {$issuesList}")
7✔
578
                                        ->setResource($this->getConfigureCheckResourceName())
7✔
579
                                        ->setTip($this->getCertificateRegenerationTip() . ' (recommended but not required)');
7✔
580
                        }
581

582
                        return null;
×
583

584
                } catch (\Exception $e) {
×
585
                        return (new ConfigureCheckHelper())
×
586
                                ->setErrorMessage('Failed to analyze root certificate: ' . $e->getMessage())
×
587
                                ->setResource($this->getConfigureCheckResourceName())
×
588
                                ->setTip('Check if the root certificate file is valid');
×
589
                }
590
        }
591

592
        private function validateLibresignCaUuidInCertificate(array $parsed): bool {
593
                if (!isset($parsed['subject']['OU'])) {
7✔
594
                        return false;
7✔
595
                }
596

597
                $instanceId = $this->getLibreSignInstanceId();
×
598
                if (empty($instanceId)) {
×
599
                        return false;
×
600
                }
601

602
                $organizationalUnits = $parsed['subject']['OU'];
×
603

604
                if (is_string($organizationalUnits)) {
×
605
                        if (str_contains($organizationalUnits, ', ')) {
×
606
                                $organizationalUnits = explode(', ', $organizationalUnits);
×
607
                        } else {
608
                                $organizationalUnits = [$organizationalUnits];
×
609
                        }
610
                }
611

612
                foreach ($organizationalUnits as $ou) {
×
613
                        $ou = trim($ou);
×
614
                        if ($this->caIdentifierService->isValidCaId($ou, $instanceId)) {
×
615
                                return true;
×
616
                        }
617
                }
618

619
                return false;
×
620
        }
621

622
        private function getLibreSignInstanceId(): string {
623
                $instanceId = $this->appConfig->getValueString(Application::APP_ID, 'instance_id', '');
×
624
                if (strlen($instanceId) === 10) {
×
625
                        return $instanceId;
×
626
                }
627
                return '';
×
628
        }
629

630
        private function calculateRemainingDays(int $validToTimestamp): int {
631
                $secondsPerDay = 60 * 60 * 24;
26✔
632
                $remainingSeconds = $validToTimestamp - time();
26✔
633
                return (int)ceil($remainingSeconds / $secondsPerDay);
26✔
634
        }
635

636
        protected function checkRootCertificateExpiry(): ?ConfigureCheckHelper {
637
                $configPath = $this->getCurrentConfigPath();
7✔
638
                $caCertPath = $configPath . DIRECTORY_SEPARATOR . 'ca.pem';
7✔
639

640
                if (!file_exists($caCertPath)) {
7✔
NEW
641
                        return null;
×
642
                }
643

644
                $certContent = file_get_contents($caCertPath);
7✔
645
                if (!$certContent) {
7✔
NEW
646
                        return null;
×
647
                }
648

649
                $x509Resource = openssl_x509_read($certContent);
7✔
650
                if (!$x509Resource) {
7✔
NEW
651
                        return null;
×
652
                }
653

654
                $parsed = openssl_x509_parse($x509Resource);
7✔
655
                if (!$parsed) {
7✔
NEW
656
                        return null;
×
657
                }
658

659
                $remainingDays = $this->calculateRemainingDays($parsed['validTo_time_t']);
7✔
660
                $leafExpiryDays = $this->getLeafExpiryInDays();
7✔
661

662
                if ($remainingDays < 0) {
7✔
NEW
663
                        return (new ConfigureCheckHelper())
×
NEW
664
                                ->setErrorMessage('Root certificate has expired')
×
NEW
665
                                ->setResource($this->getConfigureCheckResourceName())
×
NEW
666
                                ->setTip($this->getCertificateRegenerationTip() . ' URGENT: Certificate is expired!');
×
667
                }
668

669
                if ($remainingDays <= 7) {
7✔
670
                        return (new ConfigureCheckHelper())
2✔
671
                                ->setErrorMessage("Root certificate expires in {$remainingDays} days")
2✔
672
                                ->setResource($this->getConfigureCheckResourceName())
2✔
673
                                ->setTip($this->getCertificateRegenerationTip() . ' URGENT: Renew immediately!');
2✔
674
                }
675

676
                if ($remainingDays <= 30) {
5✔
677
                        return (new ConfigureCheckHelper())
1✔
678
                                ->setErrorMessage("Root certificate expires in {$remainingDays} days")
1✔
679
                                ->setResource($this->getConfigureCheckResourceName())
1✔
680
                                ->setTip($this->getCertificateRegenerationTip() . ' Renewal recommended soon.');
1✔
681
                }
682

683
                if ($remainingDays <= $leafExpiryDays) {
4✔
684
                        return (new ConfigureCheckHelper())
2✔
685
                                ->setInfoMessage("Root certificate expires in {$remainingDays} days (leaf validity: {$leafExpiryDays} days)")
2✔
686
                                ->setResource($this->getConfigureCheckResourceName())
2✔
687
                                ->setTip('Root certificate should be renewed to ensure it can sign CRLs for all issued leaf certificates.');
2✔
688
                }
689

690
                return null;
2✔
691
        }
692

693
        #[\Override]
694
        public function toArray(): array {
695
                $return = [
1✔
696
                        'configPath' => $this->getCurrentConfigPath(),
1✔
697
                        'generated' => $this->isSetupOk(),
1✔
698
                        'rootCert' => [
1✔
699
                                'commonName' => $this->getCommonName(),
1✔
700
                                'names' => [],
1✔
701
                        ],
1✔
702
                ];
1✔
703
                $return = array_merge(
1✔
704
                        $return,
1✔
705
                        $this->getCertificatePolicy(),
1✔
706
                );
1✔
707
                $names = $this->getNames();
1✔
708
                foreach ($names as $name => $value) {
1✔
709
                        $return['rootCert']['names'][] = [
×
710
                                'id' => $name,
×
711
                                'value' => $value,
×
712
                        ];
×
713
                }
714
                return $return;
1✔
715
        }
716

717
        protected function getCrlDistributionUrl(): string {
718
                $caIdParsed = $this->caIdentifierService->getCaIdParsed();
49✔
719
                return $this->urlGenerator->linkToRouteAbsolute('libresign.crl.getRevocationList', [
49✔
720
                        'instanceId' => $caIdParsed['instanceId'],
49✔
721
                        'generation' => $caIdParsed['generation'],
49✔
722
                        'engineType' => $caIdParsed['engineType'],
49✔
723
                ]);
49✔
724
        }
725

726
        private function validateCrlFromUrls(array $crlUrls, string $certPem): string {
727
                if (empty($crlUrls)) {
7✔
728
                        return 'no_urls';
×
729
                }
730

731
                $accessibleUrls = 0;
7✔
732
                foreach ($crlUrls as $crlUrl) {
7✔
733
                        try {
734
                                $validationResult = $this->downloadAndValidateCrl($crlUrl, $certPem);
7✔
735
                                if ($validationResult === 'valid') {
7✔
736
                                        return 'valid';
1✔
737
                                }
738
                                if ($validationResult === 'revoked') {
6✔
739
                                        return 'revoked';
×
740
                                }
741
                                $accessibleUrls++;
6✔
742
                        } catch (\Exception $e) {
×
743
                                continue;
×
744
                        }
745
                }
746

747
                if ($accessibleUrls === 0) {
6✔
748
                        return 'urls_inaccessible';
×
749
                }
750

751
                return 'validation_failed';
6✔
752
        }
753

754
        private function downloadAndValidateCrl(string $crlUrl, string $certPem): string {
755
                try {
756
                        if ($this->isLocalCrlUrl($crlUrl)) {
7✔
757
                                $crlContent = $this->generateLocalCrl($crlUrl);
7✔
758
                        } else {
759
                                $crlContent = $this->downloadCrlContent($crlUrl);
×
760
                        }
761

762
                        if (!$crlContent) {
7✔
763
                                throw new \Exception('Failed to get CRL content');
6✔
764
                        }
765

766
                        return $this->checkCertificateInCrl($certPem, $crlContent);
1✔
767

768
                } catch (\Exception $e) {
6✔
769
                        return 'validation_error';
6✔
770
                }
771
        }
772

773
        private function isLocalCrlUrl(string $url): bool {
774
                $host = parse_url($url, PHP_URL_HOST);
7✔
775
                if (!$host) {
7✔
776
                        return false;
×
777
                }
778

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

781
                return in_array($host, $trustedDomains, true);
7✔
782
        }
783

784
        private function generateLocalCrl(string $crlUrl): ?string {
785
                try {
786
                        $templateUrl = $this->urlGenerator->linkToRouteAbsolute('libresign.crl.getRevocationList', [
7✔
787
                                'instanceId' => 'INSTANCEID',
7✔
788
                                'generation' => 999999,
7✔
789
                                'engineType' => 'ENGINETYPE',
7✔
790
                        ]);
7✔
791

792
                        $patternUrl = str_replace('INSTANCEID', '([^/_]+)', $templateUrl);
7✔
793
                        $patternUrl = str_replace('999999', '(\d+)', $patternUrl);
7✔
794
                        $patternUrl = str_replace('ENGINETYPE', '([^/_]+)', $patternUrl);
7✔
795

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

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

800
                        $pattern = '/^' . $escapedPattern . '$/';
7✔
801
                        if (preg_match($pattern, $crlUrl, $matches)) {
7✔
802
                                $instanceId = $matches[1];
7✔
803
                                $generation = (int)$matches[2];
7✔
804
                                $engineType = $matches[3];
7✔
805

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

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

811
                                return $crlData;
1✔
812
                        }
813

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

817
                } catch (\Exception $e) {
6✔
818
                        $this->logger->warning('Failed to generate local CRL: ' . $e->getMessage());
6✔
819
                        return null;
6✔
820
                }
821
        }
822

823
        private function downloadCrlContent(string $url): ?string {
824
                if (!filter_var($url, FILTER_VALIDATE_URL) || !in_array(parse_url($url, PHP_URL_SCHEME), ['http', 'https'])) {
×
825
                        return null;
×
826
                }
827

828
                $context = stream_context_create([
×
829
                        'http' => [
×
830
                                'timeout' => 30,
×
831
                                'user_agent' => 'LibreSign/1.0 CRL Validator',
×
832
                                'follow_location' => 1,
×
833
                                'max_redirects' => 3,
×
834
                        ]
×
835
                ]);
×
836

837
                $content = @file_get_contents($url, false, $context);
×
838
                return $content !== false ? $content : null;
×
839
        }
840

841
        private function isSerialNumberInCrl(string $crlText, string $serialNumber): bool {
842
                $normalizedSerial = strtoupper($serialNumber);
1✔
843
                $normalizedSerial = ltrim($normalizedSerial, '0') ?: '0';
1✔
844

845
                return preg_match('/Serial Number: 0*' . preg_quote($normalizedSerial, '/') . '/', $crlText) === 1;
1✔
846
        }
847

848
        private function checkCertificateInCrl(string $certPem, string $crlContent): string {
849
                try {
850
                        $certResource = openssl_x509_read($certPem);
1✔
851
                        if (!$certResource) {
1✔
852
                                return 'validation_error';
×
853
                        }
854

855
                        $certData = openssl_x509_parse($certResource);
1✔
856
                        if (!isset($certData['serialNumber'])) {
1✔
857
                                return 'validation_error';
×
858
                        }
859

860
                        $tempCrlFile = $this->tempManager->getTemporaryFile('.crl');
1✔
861
                        file_put_contents($tempCrlFile, $crlContent);
1✔
862

863
                        try {
864
                                $crlTextCmd = sprintf(
1✔
865
                                        'openssl crl -in %s -inform DER -text -noout',
1✔
866
                                        escapeshellarg($tempCrlFile)
1✔
867
                                );
1✔
868

869
                                exec($crlTextCmd, $output, $exitCode);
1✔
870

871
                                if ($exitCode === 0) {
1✔
872
                                        $crlText = implode("\n", $output);
1✔
873

874
                                        if ($this->isSerialNumberInCrl($crlText, $certData['serialNumber'])
1✔
875
                                                || (!empty($certData['serialNumberHex']) && $this->isSerialNumberInCrl($crlText, $certData['serialNumberHex']))) {
1✔
876
                                                return 'revoked';
×
877
                                        }
878

879
                                        return 'valid';
1✔
880
                                }
881

882
                                return 'validation_error';
×
883

884
                        } finally {
885
                                if (file_exists($tempCrlFile)) {
1✔
886
                                        unlink($tempCrlFile);
1✔
887
                                }
888
                        }
889

890
                } catch (\Exception $e) {
×
891
                        return 'validation_error';
×
892
                }
893
        }
894

895
        #[\Override]
896
        public function generateCrlDer(array $revokedCertificates, string $instanceId, int $generation, int $crlNumber): string {
897
                $configPath = $this->getConfigPathByParams($instanceId, $generation);
24✔
898
                $issuer = $this->loadCaIssuer($configPath);
19✔
899
                $signedCrl = $this->createAndSignCrl($issuer, $revokedCertificates, $crlNumber);
19✔
900
                $crlDerData = $this->saveCrlToDer($signedCrl, $configPath);
19✔
901

902
                return $crlDerData;
19✔
903
        }
904

905
        private function loadCaIssuer(string $configPath): \phpseclib3\File\X509 {
906
                $caCertPath = $configPath . DIRECTORY_SEPARATOR . 'ca.pem';
19✔
907
                $caKeyPath = $configPath . DIRECTORY_SEPARATOR . 'ca-key.pem';
19✔
908

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

914
                $caCert = file_get_contents($caCertPath);
19✔
915
                $caKey = file_get_contents($caKeyPath);
19✔
916

917
                if (!$caCert || !$caKey) {
19✔
918
                        $this->logger->error('Failed to read CA certificate or private key', ['caCertPath' => $caCertPath, 'caKeyPath' => $caKeyPath]);
×
919
                        throw new \RuntimeException('Failed to read CA certificate or private key');
×
920
                }
921

922
                $issuer = new \phpseclib3\File\X509();
19✔
923
                $issuer->loadX509($caCert);
19✔
924
                $caPrivateKey = \phpseclib3\Crypt\PublicKeyLoader::load($caKey);
19✔
925

926
                if (!$caPrivateKey instanceof \phpseclib3\Crypt\Common\PrivateKey) {
19✔
927
                        $this->logger->error('Loaded key is not a private key', ['keyType' => get_class($caPrivateKey)]);
×
928
                        throw new \RuntimeException('Loaded key is not a private key');
×
929
                }
930

931
                $issuer->setPrivateKey($caPrivateKey);
19✔
932
                return $issuer;
19✔
933
        }
934

935
        private function createAndSignCrl(\phpseclib3\File\X509 $issuer, array $revokedCertificates, int $crlNumber): array {
936
                $utcZone = new \DateTimeZone('UTC');
19✔
937
                $crlToSign = new \phpseclib3\File\X509();
19✔
938
                $crlToSign->setSerialNumber((string)$crlNumber, 10);
19✔
939
                $crlToSign->setStartDate(new \DateTime('now', $utcZone));
19✔
940
                $crlToSign->setEndDate(new \DateTime('+7 days', $utcZone));
19✔
941

942
                $initialCrl = $crlToSign->signCRL($issuer, $crlToSign);
19✔
943
                if ($initialCrl === false) {
19✔
944
                        $this->logger->error('Failed to create initial CRL structure');
×
945
                        throw new \RuntimeException('Failed to create initial CRL structure');
×
946
                }
947

948
                if (!empty($revokedCertificates)) {
19✔
949
                        $savedCrl = $crlToSign->saveCRL($initialCrl);
17✔
950
                        if ($savedCrl === false) {
17✔
951
                                $this->logger->error('Failed to save initial CRL structure');
×
952
                                throw new \RuntimeException('Failed to save initial CRL structure');
×
953
                        }
954

955
                        $crlToSign->loadCRL($savedCrl);
17✔
956

957
                        $dateFormat = 'D, d M Y H:i:s O';
17✔
958
                        foreach ($revokedCertificates as $cert) {
17✔
959
                                $serialNumber = $cert->getSerialNumber();
17✔
960
                                $normalizedSerial = ltrim($serialNumber, '0') ?: '0';
17✔
961
                                $crlToSign->revoke(
17✔
962
                                        new \phpseclib3\Math\BigInteger($normalizedSerial, 16),
17✔
963
                                        $cert->getRevokedAt()->format($dateFormat)
17✔
964
                                );
17✔
965
                        }
966

967
                        $signedCrl = $crlToSign->signCRL($issuer, $crlToSign);
17✔
968
                } else {
969
                        $signedCrl = $initialCrl;
2✔
970
                }
971

972
                if ($signedCrl === false) {
19✔
973
                        $this->logger->error('Failed to sign CRL', ['crlNumber' => $crlNumber]);
×
974
                        throw new \RuntimeException('Failed to sign CRL');
×
975
                }
976

977
                if (!isset($signedCrl['signatureAlgorithm'])) {
19✔
978
                        $signedCrl['signatureAlgorithm'] = ['algorithm' => 'sha256WithRSAEncryption'];
×
979
                }
980

981
                return $signedCrl;
19✔
982
        }
983

984
        private function saveCrlToDer(array $signedCrl, string $configPath): string {
985
                $crlDerPath = $configPath . DIRECTORY_SEPARATOR . 'crl.der';
19✔
986
                $crlToSign = new \phpseclib3\File\X509();
19✔
987

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

990
                if ($crlDerData === false) {
19✔
991
                        $this->logger->error('Failed to save CRL in DER format');
×
992
                        throw new \RuntimeException('Failed to save CRL in DER format');
×
993
                }
994

995
                if (file_put_contents($crlDerPath, $crlDerData) === false) {
19✔
996
                        $this->logger->error('Failed to write CRL DER file', ['path' => $crlDerPath]);
×
997
                        throw new \RuntimeException('Failed to write CRL DER file');
×
998
                }
999

1000
                return $crlDerData;
19✔
1001
        }
1002

1003
        #[\Override]
1004
        public function validateRootCertificate(): void {
1005
                $configPath = $this->getCurrentConfigPath();
22✔
1006
                if (empty($configPath)) {
22✔
NEW
1007
                        return;
×
1008
                }
1009

1010
                if (!is_dir($configPath)) {
22✔
NEW
1011
                        return;
×
1012
                }
1013

1014
                $rootCertPath = $configPath . DIRECTORY_SEPARATOR . 'ca.pem';
22✔
1015

1016
                if (!file_exists($rootCertPath)) {
22✔
1017
                        return;
3✔
1018
                }
1019

1020
                $rootCert = file_get_contents($rootCertPath);
19✔
1021
                if (empty($rootCert)) {
19✔
NEW
1022
                        return;
×
1023
                }
1024

1025
                $certificate = openssl_x509_read($rootCert);
19✔
1026
                if ($certificate === false) {
19✔
NEW
1027
                        throw new LibresignException('Invalid root certificate content');
×
1028
                }
1029
                $certInfo = openssl_x509_parse($certificate);
19✔
1030
                if ($certInfo === false) {
19✔
NEW
1031
                        throw new LibresignException('Failed to parse root certificate');
×
1032
                }
1033

1034
                if ($this->checkCertificateRevoked($certInfo['serialNumber'])) {
19✔
NEW
1035
                        $this->logger->error('Root certificate has been revoked', [
×
NEW
1036
                                'ca_id' => $this->getCaId(),
×
NEW
1037
                                'impact' => 'all_leaf_certificates_invalid',
×
NEW
1038
                        ]);
×
NEW
1039
                        throw new LibresignException(
×
NEW
1040
                                'Root certificate has been revoked. Please regenerate your signing certificate.',
×
NEW
1041
                                \OC\AppFramework\Http::STATUS_PRECONDITION_FAILED
×
NEW
1042
                        );
×
1043
                }
1044

1045
                if ($certInfo['validTo_time_t'] < time()) {
19✔
NEW
1046
                        $this->logger->error('Root certificate has expired', [
×
NEW
1047
                                'ca_id' => $this->getCaId(),
×
NEW
1048
                        ]);
×
NEW
1049
                        throw new LibresignException(
×
NEW
1050
                                'Root certificate expired. Please regenerate your signing certificate.',
×
NEW
1051
                                \OC\AppFramework\Http::STATUS_PRECONDITION_FAILED
×
NEW
1052
                        );
×
1053
                }
1054

1055
                $remainingDays = $this->calculateRemainingDays($certInfo['validTo_time_t']);
19✔
1056
                $leafExpiryDays = $this->getLeafExpiryInDays();
19✔
1057

1058
                if ($remainingDays <= $leafExpiryDays) {
19✔
1059
                        $this->logger->warning('Root certificate renewal needed', [
6✔
1060
                                'remaining_days' => $remainingDays,
6✔
1061
                                'leaf_expiry_days' => $leafExpiryDays,
6✔
1062
                        ]);
6✔
1063
                }
1064
        }
1065

1066
        private function checkCertificateRevoked(string $serialNumber): bool {
1067
                try {
1068
                        /** @var \OCA\Libresign\Service\CrlService */
1069
                        $crlService = \OC::$server->get(\OCA\Libresign\Service\CrlService::class);
19✔
1070
                        $status = $crlService->getCertificateStatus($serialNumber);
19✔
1071
                        return $status['status'] === 'revoked';
19✔
NEW
1072
                } catch (\Exception $e) {
×
NEW
1073
                        $this->logger->warning('Failed to check root certificate revocation status', [
×
NEW
1074
                                'error' => $e->getMessage()
×
NEW
1075
                        ]);
×
NEW
1076
                        return false;
×
1077
                }
1078
        }
1079
}
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