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

LibreSign / libresign / 19341315260

13 Nov 2025 06:09PM UTC coverage: 39.393%. First build
19341315260

Pull #5770

github

web-flow
Merge bd60f2185 into 6d1fc5acd
Pull Request #5770: feat: validate crl

49 of 184 new or added lines in 8 files covered. (26.63%)

4633 of 11761 relevant lines covered (39.39%)

3.09 hits per line

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

39.72
/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');
64✔
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)) {
7✔
94
                        throw new EmptyCertificateException();
×
95
                }
96
                $certContent = null;
7✔
97
                try {
98
                        openssl_pkcs12_export(
7✔
99
                                $certificate,
7✔
100
                                $certContent,
7✔
101
                                $privateKey,
7✔
102
                                $this->getPassword(),
7✔
103
                                $options,
7✔
104
                        );
7✔
105
                        if (!$certContent) {
7✔
106
                                throw new \Exception();
7✔
107
                        }
108
                } catch (\Throwable) {
×
109
                        throw new LibresignException('Error while creating certificate file', 500);
×
110
                }
111

112
                return $certContent;
7✔
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)) {
9✔
129
                        throw new EmptyCertificateException();
1✔
130
                }
131
                $certContent = $this->opensslPkcs12Read($certificate, $privateKey);
8✔
132

133
                $return = $this->parseX509($certContent['cert']);
6✔
134
                if (isset($certContent['extracerts'])) {
6✔
135
                        foreach ($certContent['extracerts'] as $extraCert) {
6✔
136
                                $return['extracerts'][] = $this->parseX509($extraCert);
6✔
137
                        }
138
                        $return['extracerts'] = $this->orderCertificates($return['extracerts']);
6✔
139
                }
140
                return $return;
6✔
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
        private function parseX509(string $x509): array {
152
                $parsed = openssl_x509_parse(openssl_x509_read($x509));
6✔
153

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

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

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

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

171
                return $return;
6✔
172
        }
173

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

322
                return $pkiPath;
×
323
        }
324

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

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

339
                return $pkiPath;
9✔
340
        }
341

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

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

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

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

364
                return $ownerInfo;
9✔
365
        }
366

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

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

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

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

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

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

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

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

446
        abstract protected function getConfigureCheckResourceName(): string;
447

448
        abstract protected function getCertificateRegenerationTip(): string;
449

450
        abstract protected function getEngineSpecificChecks(): array;
451

452
        abstract protected function getSetupSuccessMessage(): string;
453

454
        abstract protected function getSetupErrorMessage(): string;
455

456
        abstract protected function getSetupErrorTip(): string;
457

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

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

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

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

480
                return $checks;
×
481
        }
482

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

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

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

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

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

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

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

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

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

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

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

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

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

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

572
                        return null;
×
573

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

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

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

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

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

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

609
                return false;
×
610
        }
611

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

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

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

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

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

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

678
                return 'validation_failed';
6✔
679
        }
680

681
        private function downloadAndValidateCrl(string $crlUrl, string $certPem): string {
682
                try {
683
                        $crlContent = $this->downloadCrlContent($crlUrl);
6✔
684
                        if (!$crlContent) {
6✔
685
                                throw new \Exception('Failed to download CRL');
6✔
686
                        }
687

NEW
688
                        return $this->checkCertificateInCrl($certPem, $crlContent);
×
689

690
                } catch (\Exception $e) {
6✔
691
                        return 'validation_error';
6✔
692
                }
693
        }
694

695
        private function downloadCrlContent(string $url): ?string {
696
                if (!filter_var($url, FILTER_VALIDATE_URL) || !in_array(parse_url($url, PHP_URL_SCHEME), ['http', 'https'])) {
6✔
NEW
697
                        return null;
×
698
                }
699

700
                $context = stream_context_create([
6✔
701
                        'http' => [
6✔
702
                                'timeout' => 30,
6✔
703
                                'user_agent' => 'LibreSign/1.0 CRL Validator',
6✔
704
                                'follow_location' => 1,
6✔
705
                                'max_redirects' => 3,
6✔
706
                        ]
6✔
707
                ]);
6✔
708

709
                $url = str_replace('localhost', 'nginx', $url);
6✔
710
                $content = @file_get_contents($url, false, $context);
6✔
711
                return $content !== false ? $content : null;
6✔
712
        }
713

714
        private function isSerialNumberInCrl(string $crlText, string $serialNumber): bool {
NEW
715
                return strpos($crlText, 'Serial Number: ' . strtoupper($serialNumber)) !== false
×
NEW
716
                        || strpos($crlText, 'Serial Number: ' . $serialNumber) !== false
×
NEW
717
                        || strpos($crlText, $serialNumber) !== false;
×
718
        }
719

720
        private function checkCertificateInCrl(string $certPem, string $crlContent): string {
721
                try {
NEW
722
                        $certResource = openssl_x509_read($certPem);
×
NEW
723
                        if (!$certResource) {
×
NEW
724
                                return 'validation_error';
×
725
                        }
726

NEW
727
                        $certData = openssl_x509_parse($certResource);
×
NEW
728
                        if (!isset($certData['serialNumber'])) {
×
NEW
729
                                return 'validation_error';
×
730
                        }
731

NEW
732
                        $tempCrlFile = $this->tempManager->getTemporaryFile('.crl');
×
NEW
733
                        file_put_contents($tempCrlFile, $crlContent);
×
734

735
                        try {
NEW
736
                                $crlTextCmd = sprintf(
×
NEW
737
                                        'openssl crl -in %s -inform DER -text 2>/dev/null || openssl crl -in %s -inform PEM -text 2>/dev/null',
×
NEW
738
                                        escapeshellarg($tempCrlFile),
×
NEW
739
                                        escapeshellarg($tempCrlFile)
×
NEW
740
                                );
×
741

NEW
742
                                exec($crlTextCmd, $output, $exitCode);
×
743

NEW
744
                                if ($exitCode === 0) {
×
NEW
745
                                        $crlText = implode("\n", $output);
×
746

NEW
747
                                        if ($this->isSerialNumberInCrl($crlText, $certData['serialNumber'])
×
NEW
748
                                                || (!empty($certData['serialNumberHex']) && $this->isSerialNumberInCrl($crlText, $certData['serialNumberHex']))) {
×
NEW
749
                                                return 'revoked';
×
750
                                        }
751

NEW
752
                                        return 'valid';
×
753
                                }
754

NEW
755
                                return 'validation_error';
×
756

757
                        } finally {
NEW
758
                                if (file_exists($tempCrlFile)) {
×
NEW
759
                                        unlink($tempCrlFile);
×
760
                                }
761
                        }
762

NEW
763
                } catch (\Exception $e) {
×
NEW
764
                        return 'validation_error';
×
765
                }
766
        }
767

768
        #[\Override]
769
        public function generateCrlDer(array $revokedCertificates, string $instanceId, int $generation, int $crlNumber): string {
NEW
770
                $configPath = $this->getConfigPathByParams($instanceId, $generation);
×
NEW
771
                $caCertPath = $configPath . DIRECTORY_SEPARATOR . 'ca.pem';
×
NEW
772
                $caKeyPath = $configPath . DIRECTORY_SEPARATOR . 'ca-key.pem';
×
NEW
773
                $crlDerPath = $configPath . DIRECTORY_SEPARATOR . 'crl.der';
×
774

NEW
775
                if (!file_exists($caCertPath) || !file_exists($caKeyPath)) {
×
NEW
776
                        throw new \RuntimeException('CA certificate or private key not found. Run: occ libresign:configure:openssl');
×
777
                }
778

779
                try {
NEW
780
                        $caCert = file_get_contents($caCertPath);
×
NEW
781
                        $caKey = file_get_contents($caKeyPath);
×
782

NEW
783
                        if (!$caCert || !$caKey) {
×
NEW
784
                                throw new \RuntimeException('Failed to read CA certificate or private key');
×
785
                        }
786

NEW
787
                        $issuer = new \OCA\Libresign\Vendor\phpseclib3\File\X509();
×
NEW
788
                        $issuer->loadX509($caCert);
×
NEW
789
                        $caPrivateKey = \OCA\Libresign\Vendor\phpseclib3\Crypt\PublicKeyLoader::load($caKey);
×
790

NEW
791
                        if (!$caPrivateKey instanceof \OCA\Libresign\Vendor\phpseclib3\Crypt\Common\PrivateKey) {
×
NEW
792
                                throw new \RuntimeException('Loaded key is not a private key');
×
793
                        }
794

NEW
795
                        $issuer->setPrivateKey($caPrivateKey);
×
796

NEW
797
                        $utc = new \DateTimeZone('UTC');
×
NEW
798
                        $now = (new \DateTime())->setTimezone($utc);
×
NEW
799
                        $nextWeek = (new \DateTime('+7 days'))->setTimezone($utc);
×
800

NEW
801
                        $revokedList = [];
×
NEW
802
                        foreach ($revokedCertificates as $cert) {
×
NEW
803
                                $revokedList[] = [
×
NEW
804
                                        'userCertificate' => new \OCA\Libresign\Vendor\phpseclib3\Math\BigInteger($cert->getSerialNumber(), 16),
×
NEW
805
                                        'revocationDate' => ['utcTime' => $cert->getRevokedAt()->format('D, d M Y H:i:s O')],
×
NEW
806
                                ];
×
807
                        }
808

NEW
809
                        $crlStructure = [
×
NEW
810
                                'tbsCertList' => [
×
NEW
811
                                        'version' => 'v2',
×
NEW
812
                                        'signature' => ['algorithm' => 'sha256WithRSAEncryption'],
×
NEW
813
                                        'issuer' => $issuer->getSubjectDN(\OCA\Libresign\Vendor\phpseclib3\File\X509::DN_ARRAY),
×
NEW
814
                                        'thisUpdate' => ['utcTime' => $now],
×
NEW
815
                                        'nextUpdate' => ['utcTime' => $nextWeek],
×
NEW
816
                                        'revokedCertificates' => $revokedList,
×
NEW
817
                                ],
×
NEW
818
                                'signatureAlgorithm' => ['algorithm' => 'sha256WithRSAEncryption'],
×
NEW
819
                        ];
×
820

NEW
821
                        $crl = new \OCA\Libresign\Vendor\phpseclib3\File\X509();
×
NEW
822
                        $crl->loadCRL($crlStructure);
×
NEW
823
                        $crl->setSerialNumber((string)$crlNumber);
×
NEW
824
                        $crl->setStartDate(new \DateTime('-1 minute'));
×
NEW
825
                        $crl->setEndDate(new \DateTime('+7 days'));
×
826

NEW
827
                        $signedCrl = $crl->signCRL($issuer, $crl, 'sha256WithRSAEncryption');
×
828

NEW
829
                        if ($signedCrl === false) {
×
NEW
830
                                throw new \RuntimeException('Failed to sign CRL with phpseclib3');
×
831
                        }
832

NEW
833
                        if (!isset($signedCrl['signatureAlgorithm'])) {
×
NEW
834
                                $signedCrl['signatureAlgorithm'] = ['algorithm' => 'sha256WithRSAEncryption'];
×
835
                        }
836

NEW
837
                        $crlDerData = $crl->saveCRL($signedCrl, \OCA\Libresign\Vendor\phpseclib3\File\X509::FORMAT_DER);
×
838

NEW
839
                        if ($crlDerData === false) {
×
NEW
840
                                throw new \RuntimeException('Failed to save CRL in DER format');
×
841
                        }
842

NEW
843
                        if (file_put_contents($crlDerPath, $crlDerData) === false) {
×
NEW
844
                                throw new \RuntimeException('Failed to write CRL DER file');
×
845
                        }
846

NEW
847
                        return $crlDerData;
×
NEW
848
                } catch (\Exception $e) {
×
NEW
849
                        $this->logger->error('CRL generation failed: ' . $e->getMessage(), ['exception' => $e]);
×
NEW
850
                        throw new \RuntimeException('Failed to generate CRL: ' . $e->getMessage(), 0, $e);
×
851
                }
852
        }
853
}
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