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

LibreSign / libresign / 19653180648

24 Nov 2025 11:50PM UTC coverage: 40.299%. First build
19653180648

Pull #5770

github

web-flow
Merge ae74a735f into 328da9e69
Pull Request #5770: feat: validate crl

140 of 215 new or added lines in 8 files covered. (65.12%)

4752 of 11792 relevant lines covered (40.3%)

3.24 hits per line

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

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

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

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

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

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

113
                return $certContent;
7✔
114
        }
115

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

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

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

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

152
        private function parseX509(string $x509): array {
153
                $parsed = openssl_x509_parse(openssl_x509_read($x509));
6✔
154

155
                $return = self::convertArrayToUtf8($parsed);
6✔
156

157
                foreach (['subject', 'issuer'] as $actor) {
6✔
158
                        foreach ($return[$actor] as $part => $value) {
6✔
159
                                if (is_string($value) && str_contains($value, ', ')) {
6✔
160
                                        $return[$actor][$part] = explode(', ', $value);
×
161
                                } else {
162
                                        $return[$actor][$part] = $value;
6✔
163
                                }
164
                        }
165
                }
166

167
                $return['valid_from'] = $this->dateTimeFormatter->formatDateTime($parsed['validFrom_time_t']);
6✔
168
                $return['valid_to'] = $this->dateTimeFormatter->formatDateTime($parsed['validTo_time_t']);
6✔
169

170
                $this->addCrlValidationInfo($return, $x509);
6✔
171

172
                return $return;
6✔
173
        }
174

175
        private function addCrlValidationInfo(array &$certData, string $certPem): void {
176
                if (isset($certData['extensions']['crlDistributionPoints'])) {
6✔
177
                        $crlDistributionPoints = $certData['extensions']['crlDistributionPoints'];
6✔
178

179
                        preg_match_all('/URI:([^\s,\n]+)/', $crlDistributionPoints, $matches);
6✔
180
                        $extractedUrls = $matches[1] ?? [];
6✔
181

182
                        $certData['crl_urls'] = $extractedUrls;
6✔
183
                        $certData['crl_validation'] = $this->validateCrlFromUrls($extractedUrls, $certPem);
6✔
184
                } else {
NEW
185
                        $certData['crl_validation'] = 'missing';
×
NEW
186
                        $certData['crl_urls'] = [];
×
187
                }
188
        }
189

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

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

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

253
        public function setEngine(string $engine): void {
254
                $this->appConfig->setValueString(Application::APP_ID, 'certificate_engine', $engine);
×
255
                $this->engine = $engine;
×
256
        }
257

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

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

291
        #[\Override]
292
        public function getCurrentConfigPath(): string {
293
                if ($this->configPath) {
21✔
294
                        return $this->configPath;
20✔
295
                }
296

297
                $customConfigPath = $this->appConfig->getValueString(Application::APP_ID, 'config_path');
21✔
298
                if ($customConfigPath && is_dir($customConfigPath)) {
21✔
299
                        $this->configPath = $customConfigPath;
13✔
300
                        return $this->configPath;
13✔
301
                }
302

303
                $this->configPath = $this->initializePkiConfigPath();
9✔
304
                if (!empty($this->configPath)) {
9✔
305
                        $this->appConfig->setValueString(Application::APP_ID, 'config_path', $this->configPath);
9✔
306
                }
307
                return $this->configPath;
9✔
308
        }
309

310
        #[\Override]
311
        public function getConfigPathByParams(string $instanceId, int $generation): string {
312
                $engineName = $this->getName();
11✔
313

314
                $pkiDirName = $this->caIdentifierService->generatePkiDirectoryNameFromParams($instanceId, $generation, $engineName);
11✔
315
                $dataDir = $this->config->getSystemValue('datadirectory', \OC::$SERVERROOT . '/data/');
11✔
316
                $systemInstanceId = $this->config->getSystemValue('instanceid');
11✔
317
                $pkiPath = $dataDir . '/appdata_' . $systemInstanceId . '/libresign/' . $pkiDirName;
11✔
318

319
                if (!is_dir($pkiPath)) {
11✔
320
                        throw new \RuntimeException("Config path does not exist for instanceId: {$instanceId}, generation: {$generation}");
5✔
321
                }
322

323
                return $pkiPath;
6✔
324
        }
325

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

336
                if (!is_dir($pkiPath)) {
9✔
337
                        $this->createDirectoryWithCorrectOwnership($pkiPath);
9✔
338
                }
339

340
                return $pkiPath;
9✔
341
        }
342

343
        private function createDirectoryWithCorrectOwnership(string $path): void {
344
                $ownerInfo = $this->getFilesOwnerInfo();
9✔
345
                $fullCommand = 'mkdir -p ' . escapeshellarg($path);
9✔
346

347
                if (posix_getuid() !== $ownerInfo['uid']) {
9✔
348
                        $fullCommand = 'runuser -u ' . $ownerInfo['name'] . ' -- ' . $fullCommand;
×
349
                }
350

351
                exec($fullCommand);
9✔
352
        }
353

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

365
                return $ownerInfo;
9✔
366
        }
367

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

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

396
        public function getName(): string {
397
                $reflect = new ReflectionClass($this);
13✔
398
                $className = $reflect->getShortName();
13✔
399
                $name = strtolower(substr($className, 0, -7));
13✔
400
                return $name;
13✔
401
        }
402

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

418
        public function getUID(): string {
419
                return str_replace(' ', '+', $this->UID);
20✔
420
        }
421

422
        #[\Override]
423
        public function getLeafExpiryInDays(): int {
424
                $exp = $this->appConfig->getValueInt(Application::APP_ID, 'expiry_in_days', 365);
7✔
425
                return $exp > 0 ? $exp : 365;
7✔
426
        }
427

428
        #[\Override]
429
        public function getCaExpiryInDays(): int {
430
                $exp = $this->appConfig->getValueInt(Application::APP_ID, 'ca_expiry_in_days', 3650); // 10 years
19✔
431
                return $exp > 0 ? $exp : 3650;
19✔
432
        }
433

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

447
        abstract protected function getConfigureCheckResourceName(): string;
448

449
        abstract protected function getCertificateRegenerationTip(): string;
450

451
        abstract protected function getEngineSpecificChecks(): array;
452

453
        abstract protected function getSetupSuccessMessage(): string;
454

455
        abstract protected function getSetupErrorMessage(): string;
456

457
        abstract protected function getSetupErrorTip(): string;
458

459
        #[\Override]
460
        public function configureCheck(): array {
461
                $checks = $this->getEngineSpecificChecks();
1✔
462

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

472
                $checks[] = (new ConfigureCheckHelper())
×
473
                        ->setSuccessMessage($this->getSetupSuccessMessage())
×
474
                        ->setResource($this->getConfigureCheckResourceName());
×
475

476
                $modernFeaturesCheck = $this->checkRootCertificateModernFeatures();
×
477
                if ($modernFeaturesCheck) {
×
478
                        $checks[] = $modernFeaturesCheck;
×
479
                }
480

481
                return $checks;
×
482
        }
483

484
        protected function checkRootCertificateModernFeatures(): ?ConfigureCheckHelper {
485
                $configPath = $this->getCurrentConfigPath();
×
486
                $caCertPath = $configPath . DIRECTORY_SEPARATOR . 'ca.pem';
×
487

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

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

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

513
                        $criticalIssues = [];
×
514
                        $minorIssues = [];
×
515

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

526
                        $missingExtensions = [];
×
527
                        if (!isset($parsed['extensions']['subjectKeyIdentifier'])) {
×
528
                                $missingExtensions[] = 'Subject Key Identifier (SKI)';
×
529
                        }
530

531
                        $isSelfSigned = (isset($parsed['issuer']) && isset($parsed['subject'])
×
532
                                                        && $parsed['issuer'] === $parsed['subject']);
×
533

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

543
                        if (!isset($parsed['extensions']['crlDistributionPoints'])) {
×
544
                                $missingExtensions[] = 'CRL Distribution Points';
×
545
                        }
546

547
                        if (!empty($missingExtensions)) {
×
548
                                $extensionsList = implode(', ', $missingExtensions);
×
549
                                $minorIssues[] = "Missing modern extensions: {$extensionsList}";
×
550
                        }
551

552
                        $hasLibresignCaUuid = $this->validateLibresignCaUuidInCertificate($parsed);
×
553
                        if (!$hasLibresignCaUuid) {
×
554
                                $minorIssues[] = 'LibreSign CA UUID not found in Organizational Unit';
×
555
                        }
556

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

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

573
                        return null;
×
574

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

583
        private function validateLibresignCaUuidInCertificate(array $parsed): bool {
584
                if (!isset($parsed['subject']['OU'])) {
×
585
                        return false;
×
586
                }
587

588
                $instanceId = $this->getLibreSignInstanceId();
×
589
                if (empty($instanceId)) {
×
590
                        return false;
×
591
                }
592

593
                $organizationalUnits = $parsed['subject']['OU'];
×
594

595
                if (is_string($organizationalUnits)) {
×
596
                        if (str_contains($organizationalUnits, ', ')) {
×
597
                                $organizationalUnits = explode(', ', $organizationalUnits);
×
598
                        } else {
599
                                $organizationalUnits = [$organizationalUnits];
×
600
                        }
601
                }
602

603
                foreach ($organizationalUnits as $ou) {
×
604
                        $ou = trim($ou);
×
605
                        if ($this->caIdentifierService->isValidCaId($ou, $instanceId)) {
×
606
                                return true;
×
607
                        }
608
                }
609

610
                return false;
×
611
        }
612

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

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

645
        protected function getCrlDistributionUrl(): string {
646
                $caIdParsed = $this->caIdentifierService->getCaIdParsed();
19✔
647
                return $this->urlGenerator->linkToRouteAbsolute('libresign.crl.getRevocationList', [
19✔
648
                        'instanceId' => $caIdParsed['instanceId'],
19✔
649
                        'generation' => $caIdParsed['generation'],
19✔
650
                        'engineType' => $caIdParsed['engineType'],
19✔
651
                ]);
19✔
652
        }
653

654
        private function validateCrlFromUrls(array $crlUrls, string $certPem): string {
655
                if (empty($crlUrls)) {
6✔
NEW
656
                        return 'no_urls';
×
657
                }
658

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

675
                if ($accessibleUrls === 0) {
5✔
NEW
676
                        return 'urls_inaccessible';
×
677
                }
678

679
                return 'validation_failed';
5✔
680
        }
681

682
        private function downloadAndValidateCrl(string $crlUrl, string $certPem): string {
683
                try {
684
                        if ($this->isLocalCrlUrl($crlUrl)) {
6✔
685
                                $crlContent = $this->generateLocalCrl($crlUrl);
6✔
686
                        } else {
NEW
687
                                $crlContent = $this->downloadCrlContent($crlUrl);
×
688
                        }
689

690
                        if (!$crlContent) {
6✔
691
                                throw new \Exception('Failed to get CRL content');
5✔
692
                        }
693

694
                        return $this->checkCertificateInCrl($certPem, $crlContent);
1✔
695

696
                } catch (\Exception $e) {
5✔
697
                        return 'validation_error';
5✔
698
                }
699
        }
700

701
        private function isLocalCrlUrl(string $url): bool {
702
                $host = parse_url($url, PHP_URL_HOST);
6✔
703
                if (!$host) {
6✔
NEW
704
                        return false;
×
705
                }
706

707
                $trustedDomains = $this->config->getSystemValue('trusted_domains', []);
6✔
708

709
                return in_array($host, $trustedDomains, true);
6✔
710
        }
711

712
        private function generateLocalCrl(string $crlUrl): ?string {
713
                try {
714
                        $templateUrl = $this->urlGenerator->linkToRouteAbsolute('libresign.crl.getRevocationList', [
6✔
715
                                'instanceId' => 'INSTANCEID',
6✔
716
                                'generation' => 999999,
6✔
717
                                'engineType' => 'ENGINETYPE',
6✔
718
                        ]);
6✔
719

720
                        $patternUrl = str_replace('INSTANCEID', '([^/_]+)', $templateUrl);
6✔
721
                        $patternUrl = str_replace('999999', '(\d+)', $patternUrl);
6✔
722
                        $patternUrl = str_replace('ENGINETYPE', '([^/_]+)', $patternUrl);
6✔
723

724
                        $escapedPattern = str_replace([':', '/', '.'], ['\:', '\/', '\.'], $patternUrl);
6✔
725
                        $pattern = '/^' . $escapedPattern . '$/';
6✔
726

727
                        if (preg_match($pattern, $crlUrl, $matches)) {
6✔
728
                                $instanceId = $matches[1];
6✔
729
                                $generation = (int)$matches[2];
6✔
730
                                $engineType = $matches[3];
6✔
731

732
                                /** @var CrlService */
733
                                $crlService = \OC::$server->get(CrlService::class);
6✔
734

735
                                $crlData = $crlService->generateCrlDer($instanceId, $generation, $engineType);
6✔
736

737
                                return $crlData;
1✔
738
                        }
739

NEW
740
                        $this->logger->debug('CRL URL does not match expected pattern', ['url' => $crlUrl, 'pattern' => $pattern]);
×
NEW
741
                        return null;
×
742

743
                } catch (\Exception $e) {
5✔
744
                        $this->logger->warning('Failed to generate local CRL: ' . $e->getMessage());
5✔
745
                        return null;
5✔
746
                }
747
        }
748

749
        private function downloadCrlContent(string $url): ?string {
NEW
750
                if (!filter_var($url, FILTER_VALIDATE_URL) || !in_array(parse_url($url, PHP_URL_SCHEME), ['http', 'https'])) {
×
NEW
751
                        return null;
×
752
                }
753

NEW
754
                $context = stream_context_create([
×
NEW
755
                        'http' => [
×
NEW
756
                                'timeout' => 30,
×
NEW
757
                                'user_agent' => 'LibreSign/1.0 CRL Validator',
×
NEW
758
                                'follow_location' => 1,
×
NEW
759
                                'max_redirects' => 3,
×
NEW
760
                        ]
×
NEW
761
                ]);
×
762

NEW
763
                $content = @file_get_contents($url, false, $context);
×
NEW
764
                return $content !== false ? $content : null;
×
765
        }
766

767
        private function isSerialNumberInCrl(string $crlText, string $serialNumber): bool {
768
                return strpos($crlText, 'Serial Number: ' . strtoupper($serialNumber)) !== false
1✔
769
                        || strpos($crlText, 'Serial Number: ' . $serialNumber) !== false
1✔
770
                        || strpos($crlText, $serialNumber) !== false;
1✔
771
        }
772

773
        private function checkCertificateInCrl(string $certPem, string $crlContent): string {
774
                try {
775
                        $certResource = openssl_x509_read($certPem);
1✔
776
                        if (!$certResource) {
1✔
NEW
777
                                return 'validation_error';
×
778
                        }
779

780
                        $certData = openssl_x509_parse($certResource);
1✔
781
                        if (!isset($certData['serialNumber'])) {
1✔
NEW
782
                                return 'validation_error';
×
783
                        }
784

785
                        $tempCrlFile = $this->tempManager->getTemporaryFile('.crl');
1✔
786
                        file_put_contents($tempCrlFile, $crlContent);
1✔
787

788
                        try {
789
                                $crlTextCmd = sprintf(
1✔
790
                                        'openssl crl -in %s -inform DER -text 2>/dev/null || openssl crl -in %s -inform PEM -text 2>/dev/null',
1✔
791
                                        escapeshellarg($tempCrlFile),
1✔
792
                                        escapeshellarg($tempCrlFile)
1✔
793
                                );
1✔
794

795
                                exec($crlTextCmd, $output, $exitCode);
1✔
796

797
                                if ($exitCode === 0) {
1✔
798
                                        $crlText = implode("\n", $output);
1✔
799

800
                                        if ($this->isSerialNumberInCrl($crlText, $certData['serialNumber'])
1✔
801
                                                || (!empty($certData['serialNumberHex']) && $this->isSerialNumberInCrl($crlText, $certData['serialNumberHex']))) {
1✔
NEW
802
                                                return 'revoked';
×
803
                                        }
804

805
                                        return 'valid';
1✔
806
                                }
807

NEW
808
                                return 'validation_error';
×
809

810
                        } finally {
811
                                if (file_exists($tempCrlFile)) {
1✔
812
                                        unlink($tempCrlFile);
1✔
813
                                }
814
                        }
815

NEW
816
                } catch (\Exception $e) {
×
NEW
817
                        return 'validation_error';
×
818
                }
819
        }
820

821
        #[\Override]
822
        public function generateCrlDer(array $revokedCertificates, string $instanceId, int $generation, int $crlNumber): string {
823
                $configPath = $this->getConfigPathByParams($instanceId, $generation);
11✔
824
                $issuer = $this->loadCaIssuer($configPath);
6✔
825
                $signedCrl = $this->createAndSignCrl($issuer, $revokedCertificates, $crlNumber);
6✔
826
                $crlDerData = $this->saveCrlToDer($signedCrl, $configPath);
6✔
827

828
                return $crlDerData;
6✔
829
        }
830

831
        private function loadCaIssuer(string $configPath): \phpseclib3\File\X509 {
832
                $caCertPath = $configPath . DIRECTORY_SEPARATOR . 'ca.pem';
6✔
833
                $caKeyPath = $configPath . DIRECTORY_SEPARATOR . 'ca-key.pem';
6✔
834

835
                if (!file_exists($caCertPath) || !file_exists($caKeyPath)) {
6✔
NEW
836
                        $this->logger->error('CA certificate or private key not found', ['caCertPath' => $caCertPath, 'caKeyPath' => $caKeyPath]);
×
NEW
837
                        throw new \RuntimeException('CA certificate or private key not found. Run: occ libresign:configure:openssl');
×
838
                }
839

840
                $caCert = file_get_contents($caCertPath);
6✔
841
                $caKey = file_get_contents($caKeyPath);
6✔
842

843
                if (!$caCert || !$caKey) {
6✔
NEW
844
                        $this->logger->error('Failed to read CA certificate or private key', ['caCertPath' => $caCertPath, 'caKeyPath' => $caKeyPath]);
×
NEW
845
                        throw new \RuntimeException('Failed to read CA certificate or private key');
×
846
                }
847

848
                $issuer = new \phpseclib3\File\X509();
6✔
849
                $issuer->loadX509($caCert);
6✔
850
                $caPrivateKey = \phpseclib3\Crypt\PublicKeyLoader::load($caKey);
6✔
851

852
                if (!$caPrivateKey instanceof \phpseclib3\Crypt\Common\PrivateKey) {
6✔
NEW
853
                        $this->logger->error('Loaded key is not a private key', ['keyType' => get_class($caPrivateKey)]);
×
NEW
854
                        throw new \RuntimeException('Loaded key is not a private key');
×
855
                }
856

857
                $issuer->setPrivateKey($caPrivateKey);
6✔
858
                return $issuer;
6✔
859
        }
860

861
        private function createAndSignCrl(\phpseclib3\File\X509 $issuer, array $revokedCertificates, int $crlNumber): array {
862
                $utcZone = new \DateTimeZone('UTC');
6✔
863
                $crlToSign = new \phpseclib3\File\X509();
6✔
864
                $crlToSign->setSerialNumber((string)$crlNumber);
6✔
865
                $crlToSign->setStartDate(new \DateTime('now', $utcZone));
6✔
866
                $crlToSign->setEndDate(new \DateTime('+7 days', $utcZone));
6✔
867

868
                if (empty($revokedCertificates)) {
6✔
869
                        $signedCrl = $crlToSign->signCRL($issuer, $crlToSign);
2✔
870
                } else {
871
                        $emptyCrl = $crlToSign->signCRL($issuer, $crlToSign);
4✔
872
                        if ($emptyCrl === false) {
4✔
NEW
873
                                $this->logger->error('Failed to create CRL structure');
×
NEW
874
                                throw new \RuntimeException('Failed to create CRL structure');
×
875
                        }
876

877
                        $crlToSign->loadCRL($crlToSign->saveCRL($emptyCrl));
4✔
878

879
                        $dateFormat = 'D, d M Y H:i:s O';
4✔
880
                        foreach ($revokedCertificates as $cert) {
4✔
881
                                $crlToSign->revoke(
4✔
882
                                        new \phpseclib3\Math\BigInteger($cert->getSerialNumber(), 16),
4✔
883
                                        $cert->getRevokedAt()->format($dateFormat)
4✔
884
                                );
4✔
885
                        }
886

887
                        $signedCrl = $crlToSign->signCRL($issuer, $crlToSign);
4✔
888
                }
889

890
                if ($signedCrl === false) {
6✔
NEW
891
                        $this->logger->error('Failed to sign CRL', ['crlNumber' => $crlNumber]);
×
NEW
892
                        throw new \RuntimeException('Failed to sign CRL');
×
893
                }
894

895
                if (!isset($signedCrl['signatureAlgorithm'])) {
6✔
NEW
896
                        $signedCrl['signatureAlgorithm'] = ['algorithm' => 'sha256WithRSAEncryption'];
×
897
                }
898

899
                return $signedCrl;
6✔
900
        }
901

902
        private function saveCrlToDer(array $signedCrl, string $configPath): string {
903
                $crlDerPath = $configPath . DIRECTORY_SEPARATOR . 'crl.der';
6✔
904
                $crlToSign = new \phpseclib3\File\X509();
6✔
905

906
                $crlDerData = $crlToSign->saveCRL($signedCrl, \phpseclib3\File\X509::FORMAT_DER);
6✔
907

908
                if ($crlDerData === false) {
6✔
NEW
909
                        $this->logger->error('Failed to save CRL in DER format');
×
NEW
910
                        throw new \RuntimeException('Failed to save CRL in DER format');
×
911
                }
912

913
                if (file_put_contents($crlDerPath, $crlDerData) === false) {
6✔
NEW
914
                        $this->logger->error('Failed to write CRL DER file', ['path' => $crlDerPath]);
×
NEW
915
                        throw new \RuntimeException('Failed to write CRL DER file');
×
916
                }
917

918
                return $crlDerData;
6✔
919
        }
920
}
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