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

LibreSign / libresign / 21460018418

28 Jan 2026 11:54PM UTC coverage: 46.393%. First build
21460018418

Pull #6602

github

web-flow
Merge 5f7d997db into 01661f344
Pull Request #6602: feat: crl validation improvements

36 of 57 new or added lines in 1 file covered. (63.16%)

7820 of 16856 relevant lines covered (46.39%)

5.07 hits per line

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

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

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

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

144
        public function getCaId(): string {
145
                $caId = $this->caIdentifierService->getCaId();
54✔
146
                if (empty($caId)) {
54✔
147
                        $this->appConfig->clearCache(true);
4✔
148
                        $caId = $this->caIdentifierService->getCaId() ?: $this->caIdentifierService->generateCaId($this->getName());
4✔
149
                }
150
                return $caId;
54✔
151
        }
152

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

158
        private function parseX509(string $x509): array {
159
                $parsed = openssl_x509_parse(openssl_x509_read($x509));
10✔
160

161
                $return = self::convertArrayToUtf8($parsed);
10✔
162

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

173
                $return['valid_from'] = $this->dateTimeFormatter->formatDateTime($parsed['validFrom_time_t']);
10✔
174
                $return['valid_to'] = $this->dateTimeFormatter->formatDateTime($parsed['validTo_time_t']);
10✔
175

176
                $this->addCrlValidationInfo($return, $x509);
10✔
177

178
                return $return;
10✔
179
        }
180

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

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

188
                        $certData['crl_urls'] = $extractedUrls;
9✔
189
                        $crlDetails = $this->validateCrlFromUrlsWithDetails($extractedUrls, $certPem);
9✔
190
                        $certData['crl_validation'] = $crlDetails['status'];
9✔
191
                        if (!empty($crlDetails['revoked_at'])) {
9✔
NEW
192
                                $certData['crl_revoked_at'] = $crlDetails['revoked_at'];
×
193
                        }
194
                } else {
195
                        $certData['crl_validation'] = 'missing';
2✔
196
                        $certData['crl_urls'] = [];
2✔
197
                }
198
        }
199

200
        private static function convertArrayToUtf8($array) {
201
                foreach ($array as $key => $value) {
10✔
202
                        if (is_array($value)) {
10✔
203
                                $array[$key] = self::convertArrayToUtf8($value);
10✔
204
                        } elseif (is_string($value)) {
10✔
205
                                $array[$key] = mb_convert_encoding($value, 'UTF-8', 'UTF-8');
10✔
206
                        }
207
                }
208
                return $array;
10✔
209
        }
210

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

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

263
        #[\Override]
264
        public function setEngine(string $engine): void {
265
                $this->appConfig->setValueString(Application::APP_ID, 'certificate_engine', $engine);
16✔
266
                $this->engine = $engine;
16✔
267
                $this->configureIdentifyMethodsForEngine($engine);
16✔
268
        }
269

270
        /**
271
         * Configure identification methods based on the certificate engine.
272
         *
273
         * When the engine is set to 'none', only the 'account' identification method
274
         * is allowed. This is because:
275
         * - The 'none' engine doesn't generate digital certificates
276
         * - Without certificates, only basic password authentication is viable
277
         * - The 'account' method ensures users authenticate with their Nextcloud credentials
278
         *
279
         * For other engines (openssl, cfssl, java), the identification methods remain
280
         * unchanged to preserve existing configurations.
281
         *
282
         * @param string $engine The certificate engine name (i.e. 'none', 'openssl', 'cfssl')
283
         */
284
        private function configureIdentifyMethodsForEngine(string $engine): void {
285
                if ($engine !== 'none') {
16✔
286
                        return;
10✔
287
                }
288

289
                $config = [[
6✔
290
                        'name' => 'account',
6✔
291
                        'enabled' => true,
6✔
292
                        'mandatory' => true,
6✔
293
                ]];
6✔
294
                $this->appConfig->setValueArray(Application::APP_ID, 'identify_methods', $config);
6✔
295
        }
296

297
        #[\Override]
298
        public function getEngine(): string {
299
                if ($this->engine) {
3✔
300
                        return $this->engine;
3✔
301
                }
302
                $this->engine = $this->appConfig->getValueString(Application::APP_ID, 'certificate_engine', 'openssl');
×
303
                return $this->engine;
×
304
        }
305

306
        #[\Override]
307
        public function populateInstance(array $rootCert): IEngineHandler {
308
                if (empty($rootCert)) {
21✔
309
                        $rootCert = $this->appConfig->getValueArray(Application::APP_ID, 'rootCert');
21✔
310
                }
311
                if (!$rootCert) {
21✔
312
                        return $this;
21✔
313
                }
314
                if (!empty($rootCert['names'])) {
×
315
                        foreach ($rootCert['names'] as $id => $customName) {
×
316
                                $longCustomName = $this->translateToLong($id);
×
317
                                // Prevent to save a property that don't exists
318
                                if (!property_exists($this, lcfirst($longCustomName))) {
×
319
                                        continue;
×
320
                                }
321
                                $this->{'set' . ucfirst($longCustomName)}($customName['value']);
×
322
                        }
323
                }
324
                if (!$this->getCommonName()) {
×
325
                        $this->setCommonName($rootCert['commonName']);
×
326
                }
327
                return $this;
×
328
        }
329

330
        #[\Override]
331
        public function getCurrentConfigPath(): string {
332
                if ($this->configPath) {
64✔
333
                        return $this->configPath;
61✔
334
                }
335

336
                $customConfigPath = $this->appConfig->getValueString(Application::APP_ID, 'config_path');
54✔
337
                if ($customConfigPath && is_dir($customConfigPath)) {
54✔
338
                        $this->configPath = $customConfigPath;
9✔
339
                        return $this->configPath;
9✔
340
                }
341

342
                $this->configPath = $this->initializePkiConfigPath();
54✔
343
                if (!empty($this->configPath)) {
54✔
344
                        $this->appConfig->setValueString(Application::APP_ID, 'config_path', $this->configPath);
54✔
345
                }
346
                return $this->configPath;
54✔
347
        }
348

349
        #[\Override]
350
        public function getConfigPathByParams(string $instanceId, int $generation): string {
351
                $engineName = $this->getName();
26✔
352

353
                $pkiDirName = $this->caIdentifierService->generatePkiDirectoryNameFromParams($instanceId, $generation, $engineName);
26✔
354
                $dataDir = $this->config->getSystemValue('datadirectory', \OC::$SERVERROOT . '/data/');
26✔
355
                $systemInstanceId = $this->config->getSystemValue('instanceid');
26✔
356
                $pkiPath = $dataDir . '/appdata_' . $systemInstanceId . '/libresign/' . $pkiDirName;
26✔
357

358
                if (!is_dir($pkiPath)) {
26✔
359
                        throw new \RuntimeException("Config path does not exist for instanceId: {$instanceId}, generation: {$generation}");
2✔
360
                }
361

362
                return $pkiPath;
24✔
363
        }
364

365
        private function initializePkiConfigPath(): string {
366
                $caId = $this->getCaId();
54✔
367
                if (empty($caId)) {
54✔
368
                        return '';
×
369
                }
370
                $pkiDirName = $this->caIdentifierService->generatePkiDirectoryName($caId);
54✔
371
                $dataDir = $this->config->getSystemValue('datadirectory', \OC::$SERVERROOT . '/data/');
54✔
372
                $systemInstanceId = $this->config->getSystemValue('instanceid');
54✔
373
                $pkiPath = $dataDir . '/appdata_' . $systemInstanceId . '/libresign/' . $pkiDirName;
54✔
374

375
                if (!is_dir($pkiPath)) {
54✔
376
                        $this->createDirectoryWithCorrectOwnership($pkiPath);
54✔
377
                }
378

379
                return $pkiPath;
54✔
380
        }
381

382
        private function createDirectoryWithCorrectOwnership(string $path): void {
383
                $ownerInfo = $this->getFilesOwnerInfo();
54✔
384
                $fullCommand = 'mkdir -p ' . escapeshellarg($path);
54✔
385

386
                if (posix_getuid() !== $ownerInfo['uid']) {
54✔
387
                        $fullCommand = 'runuser -u ' . $ownerInfo['name'] . ' -- ' . $fullCommand;
×
388
                }
389

390
                exec($fullCommand);
54✔
391
        }
392

393
        private function getFilesOwnerInfo(): array {
394
                $currentFile = realpath(__DIR__);
54✔
395
                $owner = fileowner($currentFile);
54✔
396
                if ($owner === false) {
54✔
397
                        throw new \RuntimeException('Unable to get file information');
×
398
                }
399
                $ownerInfo = posix_getpwuid($owner);
54✔
400
                if ($ownerInfo === false) {
54✔
401
                        throw new \RuntimeException('Unable to get file owner information');
×
402
                }
403

404
                return $ownerInfo;
54✔
405
        }
406

407
        /**
408
         * @todo check a best solution to don't use reflection
409
         */
410
        private function getInternalPathOfFolder(ISimpleFolder $node): string {
411
                $reflection = new \ReflectionClass($node);
×
412
                $reflectionProperty = $reflection->getProperty('folder');
×
413
                $folder = $reflectionProperty->getValue($node);
×
414
                $path = $folder->getInternalPath();
×
415
                return $path;
×
416
        }
417

418
        #[\Override]
419
        public function setConfigPath(string $configPath): IEngineHandler {
420
                if (!$configPath) {
8✔
421
                        $this->appConfig->deleteKey(Application::APP_ID, 'config_path');
×
422
                } else {
423
                        if (!is_dir($configPath)) {
8✔
424
                                mkdir(
×
425
                                        directory: $configPath,
×
426
                                        recursive: true,
×
427
                                );
×
428
                        }
429
                        $this->appConfig->setValueString(Application::APP_ID, 'config_path', $configPath);
8✔
430
                }
431
                $this->configPath = $configPath;
8✔
432
                return $this;
8✔
433
        }
434

435
        public function getName(): string {
436
                $reflect = new ReflectionClass($this);
30✔
437
                $className = $reflect->getShortName();
30✔
438
                $name = strtolower(substr($className, 0, -7));
30✔
439
                return $name;
30✔
440
        }
441

442
        protected function getNames(): array {
443
                $names = [
60✔
444
                        'C' => $this->getCountry(),
60✔
445
                        'ST' => $this->getState(),
60✔
446
                        'L' => $this->getLocality(),
60✔
447
                        'O' => $this->getOrganization(),
60✔
448
                        'OU' => $this->getOrganizationalUnit(),
60✔
449
                ];
60✔
450
                if ($uid = $this->getUID()) {
60✔
451
                        $names['UID'] = $uid;
×
452
                }
453
                $names = array_filter($names, fn ($v) => !empty($v));
60✔
454
                return $names;
60✔
455
        }
456

457
        public function getUID(): string {
458
                return str_replace(' ', '+', $this->UID);
60✔
459
        }
460

461
        #[\Override]
462
        public function getLeafExpiryInDays(): int {
463
                if ($this->leafExpiryOverrideInDays !== null) {
26✔
464
                        return $this->leafExpiryOverrideInDays;
×
465
                }
466
                $exp = $this->appConfig->getValueInt(Application::APP_ID, 'expiry_in_days', 365);
26✔
467
                return $exp > 0 ? $exp : 365;
26✔
468
        }
469

470
        #[\Override]
471
        public function setLeafExpiryOverrideInDays(?int $days): self {
472
                $this->leafExpiryOverrideInDays = ($days !== null && $days > 0) ? $days : null;
×
473
                return $this;
×
474
        }
475

476
        #[\Override]
477
        public function getCaExpiryInDays(): int {
478
                $exp = $this->appConfig->getValueInt(Application::APP_ID, 'ca_expiry_in_days', 3650); // 10 years
49✔
479
                return $exp > 0 ? $exp : 3650;
49✔
480
        }
481

482
        private function getCertificatePolicy(): array {
483
                $return = ['policySection' => []];
11✔
484
                $oid = $this->certificatePolicyService->getOid();
11✔
485
                $cps = $this->certificatePolicyService->getCps();
11✔
486
                if ($oid && $cps) {
11✔
487
                        $return['policySection'][] = [
×
488
                                'OID' => $oid,
×
489
                                'CPS' => $cps,
×
490
                        ];
×
491
                }
492
                return $return;
11✔
493
        }
494

495
        abstract protected function getConfigureCheckResourceName(): string;
496

497
        abstract protected function getCertificateRegenerationTip(): string;
498

499
        abstract protected function getEngineSpecificChecks(): array;
500

501
        abstract protected function getSetupSuccessMessage(): string;
502

503
        abstract protected function getSetupErrorMessage(): string;
504

505
        abstract protected function getSetupErrorTip(): string;
506

507
        #[\Override]
508
        public function configureCheck(): array {
509
                $checks = $this->getEngineSpecificChecks();
8✔
510

511
                if (!$this->isSetupOk()) {
8✔
512
                        return array_merge($checks, [
1✔
513
                                (new ConfigureCheckHelper())
1✔
514
                                        ->setErrorMessage($this->getSetupErrorMessage())
1✔
515
                                        ->setResource($this->getConfigureCheckResourceName())
1✔
516
                                        ->setTip($this->getSetupErrorTip())
1✔
517
                        ]);
1✔
518
                }
519

520
                $checks[] = (new ConfigureCheckHelper())
7✔
521
                        ->setSuccessMessage($this->getSetupSuccessMessage())
7✔
522
                        ->setResource($this->getConfigureCheckResourceName());
7✔
523

524
                $modernFeaturesCheck = $this->checkRootCertificateModernFeatures();
7✔
525
                if ($modernFeaturesCheck) {
7✔
526
                        $checks[] = $modernFeaturesCheck;
7✔
527
                }
528

529
                $expiryCheck = $this->checkRootCertificateExpiry();
7✔
530
                if ($expiryCheck) {
7✔
531
                        $checks[] = $expiryCheck;
5✔
532
                }
533

534
                return $checks;
7✔
535
        }
536

537
        protected function checkRootCertificateModernFeatures(): ?ConfigureCheckHelper {
538
                $configPath = $this->getCurrentConfigPath();
7✔
539
                $caCertPath = $configPath . DIRECTORY_SEPARATOR . 'ca.pem';
7✔
540

541
                try {
542
                        $certContent = file_get_contents($caCertPath);
7✔
543
                        if (!$certContent) {
7✔
544
                                return (new ConfigureCheckHelper())
×
545
                                        ->setErrorMessage('Failed to read root certificate file')
×
546
                                        ->setResource($this->getConfigureCheckResourceName())
×
547
                                        ->setTip('Check file permissions and disk space');
×
548
                        }
549

550
                        $x509Resource = openssl_x509_read($certContent);
7✔
551
                        if (!$x509Resource) {
7✔
552
                                return (new ConfigureCheckHelper())
×
553
                                        ->setErrorMessage('Failed to parse root certificate')
×
554
                                        ->setResource($this->getConfigureCheckResourceName())
×
555
                                        ->setTip('Root certificate file may be corrupted or invalid');
×
556
                        }
557

558
                        $parsed = openssl_x509_parse($x509Resource);
7✔
559
                        if (!$parsed) {
7✔
560
                                return (new ConfigureCheckHelper())
×
561
                                        ->setErrorMessage('Failed to extract root certificate information')
×
562
                                        ->setResource($this->getConfigureCheckResourceName())
×
563
                                        ->setTip('Root certificate may be in an unsupported format');
×
564
                        }
565

566
                        $criticalIssues = [];
7✔
567
                        $minorIssues = [];
7✔
568

569
                        if (isset($parsed['serialNumber'])) {
7✔
570
                                $serialNumber = $parsed['serialNumber'];
7✔
571
                                $serialDecimal = hexdec($serialNumber);
7✔
572
                                if ($serialDecimal <= 1) {
7✔
573
                                        $minorIssues[] = 'Serial number is simple (zero or one)';
×
574
                                }
575
                        } else {
576
                                $criticalIssues[] = 'Serial number is missing';
×
577
                        }
578

579
                        $missingExtensions = [];
7✔
580
                        if (!isset($parsed['extensions']['subjectKeyIdentifier'])) {
7✔
581
                                $missingExtensions[] = 'Subject Key Identifier (SKI)';
×
582
                        }
583

584
                        $isSelfSigned = (isset($parsed['issuer']) && isset($parsed['subject'])
7✔
585
                                                        && $parsed['issuer'] === $parsed['subject']);
7✔
586

587
                        /**
588
                         * @todo workarround for missing AKI at certificates generated by CFSSL.
589
                         *
590
                         * CFSSL does not add Authority Key Identifier (AKI) to self-signed root certificates.
591
                         */
592
                        if (!$isSelfSigned && !isset($parsed['extensions']['authorityKeyIdentifier'])) {
7✔
593
                                $missingExtensions[] = 'Authority Key Identifier (AKI)';
×
594
                        }
595

596
                        if (!isset($parsed['extensions']['crlDistributionPoints'])) {
7✔
597
                                $missingExtensions[] = 'CRL Distribution Points';
×
598
                        }
599

600
                        if (!empty($missingExtensions)) {
7✔
601
                                $extensionsList = implode(', ', $missingExtensions);
×
602
                                $minorIssues[] = "Missing modern extensions: {$extensionsList}";
×
603
                        }
604

605
                        $hasLibresignCaUuid = $this->validateLibresignCaUuidInCertificate($parsed);
7✔
606
                        if (!$hasLibresignCaUuid) {
7✔
607
                                $minorIssues[] = 'LibreSign CA UUID not found in Organizational Unit';
7✔
608
                        }
609

610
                        if (!empty($criticalIssues)) {
7✔
611
                                $issuesList = implode(', ', $criticalIssues);
×
612
                                return (new ConfigureCheckHelper())
×
613
                                        ->setErrorMessage("Root certificate has critical issues: {$issuesList}")
×
614
                                        ->setResource($this->getConfigureCheckResourceName())
×
615
                                        ->setTip($this->getCertificateRegenerationTip());
×
616
                        }
617

618
                        if (!empty($minorIssues)) {
7✔
619
                                $issuesList = implode(', ', $minorIssues);
7✔
620
                                return (new ConfigureCheckHelper())
7✔
621
                                        ->setInfoMessage("Root certificate could benefit from modern features: {$issuesList}")
7✔
622
                                        ->setResource($this->getConfigureCheckResourceName())
7✔
623
                                        ->setTip($this->getCertificateRegenerationTip() . ' (recommended but not required)');
7✔
624
                        }
625

626
                        return null;
×
627

628
                } catch (\Exception $e) {
×
629
                        return (new ConfigureCheckHelper())
×
630
                                ->setErrorMessage('Failed to analyze root certificate: ' . $e->getMessage())
×
631
                                ->setResource($this->getConfigureCheckResourceName())
×
632
                                ->setTip('Check if the root certificate file is valid');
×
633
                }
634
        }
635

636
        private function validateLibresignCaUuidInCertificate(array $parsed): bool {
637
                if (!isset($parsed['subject']['OU'])) {
7✔
638
                        return false;
7✔
639
                }
640

641
                $instanceId = $this->getLibreSignInstanceId();
×
642
                if (empty($instanceId)) {
×
643
                        return false;
×
644
                }
645

646
                $organizationalUnits = $parsed['subject']['OU'];
×
647

648
                if (is_string($organizationalUnits)) {
×
649
                        if (str_contains($organizationalUnits, ', ')) {
×
650
                                $organizationalUnits = explode(', ', $organizationalUnits);
×
651
                        } else {
652
                                $organizationalUnits = [$organizationalUnits];
×
653
                        }
654
                }
655

656
                foreach ($organizationalUnits as $ou) {
×
657
                        $ou = trim($ou);
×
658
                        if ($this->caIdentifierService->isValidCaId($ou, $instanceId)) {
×
659
                                return true;
×
660
                        }
661
                }
662

663
                return false;
×
664
        }
665

666
        private function getLibreSignInstanceId(): string {
667
                $instanceId = $this->appConfig->getValueString(Application::APP_ID, 'instance_id', '');
×
668
                if (strlen($instanceId) === 10) {
×
669
                        return $instanceId;
×
670
                }
671
                return '';
×
672
        }
673

674
        private function calculateRemainingDays(int $validToTimestamp): int {
675
                $secondsPerDay = 60 * 60 * 24;
26✔
676
                $remainingSeconds = $validToTimestamp - time();
26✔
677
                return (int)ceil($remainingSeconds / $secondsPerDay);
26✔
678
        }
679

680
        protected function checkRootCertificateExpiry(): ?ConfigureCheckHelper {
681
                $configPath = $this->getCurrentConfigPath();
7✔
682
                $caCertPath = $configPath . DIRECTORY_SEPARATOR . 'ca.pem';
7✔
683

684
                if (!file_exists($caCertPath)) {
7✔
685
                        return null;
×
686
                }
687

688
                $certContent = file_get_contents($caCertPath);
7✔
689
                if (!$certContent) {
7✔
690
                        return null;
×
691
                }
692

693
                $x509Resource = openssl_x509_read($certContent);
7✔
694
                if (!$x509Resource) {
7✔
695
                        return null;
×
696
                }
697

698
                $parsed = openssl_x509_parse($x509Resource);
7✔
699
                if (!$parsed) {
7✔
700
                        return null;
×
701
                }
702

703
                $remainingDays = $this->calculateRemainingDays($parsed['validTo_time_t']);
7✔
704
                $leafExpiryDays = $this->getLeafExpiryInDays();
7✔
705

706
                if ($remainingDays < 0) {
7✔
707
                        return (new ConfigureCheckHelper())
×
708
                                ->setErrorMessage('Root certificate has expired')
×
709
                                ->setResource($this->getConfigureCheckResourceName())
×
710
                                ->setTip($this->getCertificateRegenerationTip() . ' URGENT: Certificate is expired!');
×
711
                }
712

713
                if ($remainingDays <= 7) {
7✔
714
                        return (new ConfigureCheckHelper())
2✔
715
                                ->setErrorMessage("Root certificate expires in {$remainingDays} days")
2✔
716
                                ->setResource($this->getConfigureCheckResourceName())
2✔
717
                                ->setTip($this->getCertificateRegenerationTip() . ' URGENT: Renew immediately!');
2✔
718
                }
719

720
                if ($remainingDays <= 30) {
5✔
721
                        return (new ConfigureCheckHelper())
1✔
722
                                ->setErrorMessage("Root certificate expires in {$remainingDays} days")
1✔
723
                                ->setResource($this->getConfigureCheckResourceName())
1✔
724
                                ->setTip($this->getCertificateRegenerationTip() . ' Renewal recommended soon.');
1✔
725
                }
726

727
                if ($remainingDays <= $leafExpiryDays) {
4✔
728
                        return (new ConfigureCheckHelper())
2✔
729
                                ->setInfoMessage("Root certificate expires in {$remainingDays} days (leaf validity: {$leafExpiryDays} days)")
2✔
730
                                ->setResource($this->getConfigureCheckResourceName())
2✔
731
                                ->setTip('Root certificate should be renewed to ensure it can sign CRLs for all issued leaf certificates.');
2✔
732
                }
733

734
                return null;
2✔
735
        }
736

737
        #[\Override]
738
        public function toArray(): array {
739
                $generated = $this->isSetupOk();
11✔
740
                $return = [
11✔
741
                        'configPath' => $this->getConfigPathForApi($generated),
11✔
742
                        'generated' => $generated,
11✔
743
                        'rootCert' => [
11✔
744
                                'commonName' => $this->getCommonName(),
11✔
745
                                'names' => [],
11✔
746
                        ],
11✔
747
                ];
11✔
748
                $return = array_merge(
11✔
749
                        $return,
11✔
750
                        $this->getCertificatePolicy(),
11✔
751
                );
11✔
752
                $names = $this->getNames();
11✔
753
                foreach ($names as $name => $value) {
11✔
754
                        $return['rootCert']['names'][] = [
6✔
755
                                'id' => $name,
6✔
756
                                'value' => $this->filterNameValue($name, $value, $generated),
6✔
757
                        ];
6✔
758
                }
759
                return $return;
11✔
760
        }
761

762
        private function getConfigPathForApi(bool $generated): string {
763
                return $generated ? $this->getCurrentConfigPath() : '';
11✔
764
        }
765

766
        private function filterNameValue(string $name, mixed $value, bool $generated): mixed {
767
                if ($name === 'OU' && is_array($value) && !$generated) {
6✔
768
                        $filtered = $this->removeCaIdFromOrganizationalUnit($value);
3✔
769
                        return empty($filtered) ? null : $filtered;
3✔
770
                }
771
                return $value;
6✔
772
        }
773

774
        private function removeCaIdFromOrganizationalUnit(array $organizationalUnits): array {
775
                $filtered = array_filter(
3✔
776
                        $organizationalUnits,
3✔
777
                        fn ($item) => !str_starts_with($item, 'libresign-ca-id:')
3✔
778
                );
3✔
779
                return array_values($filtered);
3✔
780
        }
781

782
        protected function getCrlDistributionUrl(): string {
783
                $caIdParsed = $this->caIdentifierService->getCaIdParsed();
49✔
784
                return $this->urlGenerator->linkToRouteAbsolute('libresign.crl.getRevocationList', [
49✔
785
                        'instanceId' => $caIdParsed['instanceId'],
49✔
786
                        'generation' => $caIdParsed['generation'],
49✔
787
                        'engineType' => $caIdParsed['engineType'],
49✔
788
                ]);
49✔
789
        }
790

791
        private function validateCrlFromUrls(array $crlUrls, string $certPem): string {
NEW
792
                $details = $this->validateCrlFromUrlsWithDetails($crlUrls, $certPem);
×
NEW
793
                return $details['status'];
×
794
        }
795

796
        private function validateCrlFromUrlsWithDetails(array $crlUrls, string $certPem): array {
797
                if (empty($crlUrls)) {
9✔
NEW
798
                        return ['status' => 'no_urls'];
×
799
                }
800

801
                $accessibleUrls = 0;
9✔
802
                foreach ($crlUrls as $crlUrl) {
9✔
803
                        try {
804
                                $validationResult = $this->downloadAndValidateCrlWithDetails($crlUrl, $certPem);
9✔
805
                                if ($validationResult['status'] === 'valid') {
9✔
806
                                        return $validationResult;
7✔
807
                                }
808
                                if ($validationResult['status'] === 'revoked') {
2✔
NEW
809
                                        return $validationResult;
×
810
                                }
811
                                $accessibleUrls++;
2✔
812
                        } catch (\Exception $e) {
×
813
                                continue;
×
814
                        }
815
                }
816

817
                if ($accessibleUrls === 0) {
2✔
NEW
818
                        return ['status' => 'urls_inaccessible'];
×
819
                }
820

821
                return ['status' => 'validation_failed'];
2✔
822
        }
823

824
        private function downloadAndValidateCrl(string $crlUrl, string $certPem): string {
825
                try {
826
                        if ($this->isLocalCrlUrl($crlUrl)) {
×
827
                                $crlContent = $this->generateLocalCrl($crlUrl);
×
828
                        } else {
829
                                $crlContent = $this->downloadCrlContent($crlUrl);
×
830
                        }
831

832
                        if (!$crlContent) {
×
833
                                throw new \Exception('Failed to get CRL content');
×
834
                        }
835

836
                        return $this->checkCertificateInCrl($certPem, $crlContent);
×
837

838
                } catch (\Exception $e) {
×
839
                        return 'validation_error';
×
840
                }
841
        }
842

843
        private function downloadAndValidateCrlWithDetails(string $crlUrl, string $certPem): array {
844
                try {
845
                        if ($this->isLocalCrlUrl($crlUrl)) {
9✔
846
                                $crlContent = $this->generateLocalCrl($crlUrl);
9✔
847
                        } else {
NEW
848
                                $crlContent = $this->downloadCrlContent($crlUrl);
×
849
                        }
850

851
                        if (!$crlContent) {
9✔
852
                                throw new \Exception('Failed to get CRL content');
2✔
853
                        }
854

855
                        return $this->checkCertificateInCrlWithDetails($certPem, $crlContent);
7✔
856

857
                } catch (\Exception $e) {
2✔
858
                        return ['status' => 'validation_error'];
2✔
859
                }
860
        }
861

862
        private function isLocalCrlUrl(string $url): bool {
863
                $host = parse_url($url, PHP_URL_HOST);
9✔
864
                if (!$host) {
9✔
865
                        return false;
×
866
                }
867

868
                $trustedDomains = $this->config->getSystemValue('trusted_domains', []);
9✔
869

870
                return in_array($host, $trustedDomains, true);
9✔
871
        }
872

873
        private function generateLocalCrl(string $crlUrl): ?string {
874
                try {
875
                        $templateUrl = $this->urlGenerator->linkToRouteAbsolute('libresign.crl.getRevocationList', [
9✔
876
                                'instanceId' => 'INSTANCEID',
9✔
877
                                'generation' => 999999,
9✔
878
                                'engineType' => 'ENGINETYPE',
9✔
879
                        ]);
9✔
880

881
                        $patternUrl = str_replace('INSTANCEID', '([^/_]+)', $templateUrl);
9✔
882
                        $patternUrl = str_replace('999999', '(\d+)', $patternUrl);
9✔
883
                        $patternUrl = str_replace('ENGINETYPE', '([^/_]+)', $patternUrl);
9✔
884

885
                        $escapedPattern = str_replace([':', '/', '.'], ['\:', '\/', '\.'], $patternUrl);
9✔
886

887
                        $escapedPattern = str_replace('\/apps\/', '(?:\/index\.php)?\/apps\/', $escapedPattern);
9✔
888

889
                        $pattern = '/^' . $escapedPattern . '$/';
9✔
890
                        if (preg_match($pattern, $crlUrl, $matches)) {
9✔
891
                                $instanceId = $matches[1];
9✔
892
                                $generation = (int)$matches[2];
9✔
893
                                $engineType = $matches[3];
9✔
894

895
                                /** @var \OCA\Libresign\Service\CrlService */
896
                                $crlService = \OC::$server->get(\OCA\Libresign\Service\CrlService::class);
9✔
897

898
                                $crlData = $crlService->generateCrlDer($instanceId, $generation, $engineType);
9✔
899

900
                                return $crlData;
7✔
901
                        }
902

903
                        $this->logger->debug('CRL URL does not match expected pattern', ['url' => $crlUrl, 'pattern' => $pattern]);
2✔
904
                        return null;
2✔
905

906
                } catch (\Exception $e) {
2✔
907
                        $this->logger->warning('Failed to generate local CRL: ' . $e->getMessage());
2✔
908
                        return null;
2✔
909
                }
910
        }
911

912
        private function downloadCrlContent(string $url): ?string {
913
                if (!filter_var($url, FILTER_VALIDATE_URL) || !in_array(parse_url($url, PHP_URL_SCHEME), ['http', 'https'])) {
×
914
                        return null;
×
915
                }
916

917
                $context = stream_context_create([
×
918
                        'http' => [
×
919
                                'timeout' => 30,
×
920
                                'user_agent' => 'LibreSign/1.0 CRL Validator',
×
921
                                'follow_location' => 1,
×
922
                                'max_redirects' => 3,
×
923
                        ]
×
924
                ]);
×
925

926
                $content = @file_get_contents($url, false, $context);
×
927
                return $content !== false ? $content : null;
×
928
        }
929

930
        private function isSerialNumberInCrl(string $crlText, string $serialNumber): bool {
931
                $normalizedSerial = strtoupper($serialNumber);
7✔
932
                $normalizedSerial = ltrim($normalizedSerial, '0') ?: '0';
7✔
933

934
                return preg_match('/Serial Number: 0*' . preg_quote($normalizedSerial, '/') . '/', $crlText) === 1;
7✔
935
        }
936

937
        private function checkCertificateInCrl(string $certPem, string $crlContent): string {
938
                try {
939
                        $certResource = openssl_x509_read($certPem);
×
940
                        if (!$certResource) {
×
941
                                return 'validation_error';
×
942
                        }
943

944
                        $certData = openssl_x509_parse($certResource);
×
945
                        if (!isset($certData['serialNumber'])) {
×
946
                                return 'validation_error';
×
947
                        }
948

NEW
949
                        return $this->checkCertificateInCrlWithDetails($certPem, $crlContent)['status'];
×
950

NEW
951
                } catch (\Exception $e) {
×
NEW
952
                        return 'validation_error';
×
953
                }
954
        }
955

956
        private function checkCertificateInCrlWithDetails(string $certPem, string $crlContent): array {
957
                try {
958
                        $certResource = openssl_x509_read($certPem);
7✔
959
                        if (!$certResource) {
7✔
NEW
960
                                return ['status' => 'validation_error'];
×
961
                        }
962

963
                        $certData = openssl_x509_parse($certResource);
7✔
964
                        if (!isset($certData['serialNumber'])) {
7✔
NEW
965
                                return ['status' => 'validation_error'];
×
966
                        }
967

968
                        $tempCrlFile = $this->tempManager->getTemporaryFile('.crl');
7✔
969
                        file_put_contents($tempCrlFile, $crlContent);
7✔
970

971
                        try {
972
                                $crlTextCmd = sprintf(
7✔
973
                                        'openssl crl -in %s -inform DER -text -noout',
7✔
974
                                        escapeshellarg($tempCrlFile)
7✔
975
                                );
7✔
976

977
                                exec($crlTextCmd, $output, $exitCode);
7✔
978

979
                                if ($exitCode !== 0) {
7✔
NEW
980
                                        return ['status' => 'validation_error'];
×
981
                                }
982

983
                                $crlText = implode("\n", $output);
7✔
984
                                $serialCandidates = [$certData['serialNumber']];
7✔
985
                                if (!empty($certData['serialNumberHex'])) {
7✔
986
                                        $serialCandidates[] = $certData['serialNumberHex'];
7✔
987
                                }
988

989
                                foreach ($serialCandidates as $serial) {
7✔
990
                                        if ($this->isSerialNumberInCrl($crlText, $serial)) {
7✔
NEW
991
                                                $revokedAt = $this->extractRevocationDateFromCrlText($crlText, $serialCandidates);
×
NEW
992
                                                return array_filter([
×
NEW
993
                                                        'status' => 'revoked',
×
NEW
994
                                                        'revoked_at' => $revokedAt,
×
NEW
995
                                                ]);
×
996
                                        }
997
                                }
998

999
                                return ['status' => 'valid'];
7✔
1000

1001
                        } finally {
1002
                                if (file_exists($tempCrlFile)) {
7✔
1003
                                        unlink($tempCrlFile);
7✔
1004
                                }
1005
                        }
1006

1007
                } catch (\Exception $e) {
×
NEW
1008
                        return ['status' => 'validation_error'];
×
1009
                }
1010
        }
1011

1012
        private function extractRevocationDateFromCrlText(string $crlText, array $serialNumbers): ?string {
1013
                foreach ($serialNumbers as $serial) {
3✔
1014
                        $normalizedSerial = strtoupper(ltrim((string)$serial, '0')) ?: '0';
3✔
1015
                        $pattern = '/Serial Number:\s*0*' . preg_quote($normalizedSerial, '/') . '\s*\R\s*Revocation Date:\s*([^\r\n]+)/i';
3✔
1016
                        if (preg_match($pattern, $crlText, $matches) !== 1) {
3✔
1017
                                continue;
1✔
1018
                        }
1019
                        $dateText = trim($matches[1]);
2✔
1020
                        try {
1021
                                $date = new \DateTimeImmutable($dateText, new \DateTimeZone('UTC'));
2✔
1022
                                return $date->setTimezone(new \DateTimeZone('UTC'))->format(\DateTimeInterface::ATOM);
2✔
NEW
1023
                        } catch (\Exception $e) {
×
NEW
1024
                                continue;
×
1025
                        }
1026
                }
1027
                return null;
1✔
1028
        }
1029

1030
        #[\Override]
1031
        public function generateCrlDer(array $revokedCertificates, string $instanceId, int $generation, int $crlNumber): string {
1032
                $configPath = $this->getConfigPathByParams($instanceId, $generation);
26✔
1033
                $issuer = $this->loadCaIssuer($configPath);
24✔
1034
                $signedCrl = $this->createAndSignCrl($issuer, $revokedCertificates, $crlNumber);
24✔
1035
                $crlDerData = $this->saveCrlToDer($signedCrl, $configPath);
24✔
1036

1037
                return $crlDerData;
24✔
1038
        }
1039

1040
        private function loadCaIssuer(string $configPath): \phpseclib3\File\X509 {
1041
                $caCertPath = $configPath . DIRECTORY_SEPARATOR . 'ca.pem';
24✔
1042
                $caKeyPath = $configPath . DIRECTORY_SEPARATOR . 'ca-key.pem';
24✔
1043

1044
                if (!file_exists($caCertPath) || !file_exists($caKeyPath)) {
24✔
1045
                        $this->logger->error('CA certificate or private key not found', ['caCertPath' => $caCertPath, 'caKeyPath' => $caKeyPath]);
×
1046
                        throw new \RuntimeException('CA certificate or private key not found. Run: occ libresign:configure:openssl');
×
1047
                }
1048

1049
                $caCert = file_get_contents($caCertPath);
24✔
1050
                $caKey = file_get_contents($caKeyPath);
24✔
1051

1052
                if (!$caCert || !$caKey) {
24✔
1053
                        $this->logger->error('Failed to read CA certificate or private key', ['caCertPath' => $caCertPath, 'caKeyPath' => $caKeyPath]);
×
1054
                        throw new \RuntimeException('Failed to read CA certificate or private key');
×
1055
                }
1056

1057
                $issuer = new \phpseclib3\File\X509();
24✔
1058
                $issuer->loadX509($caCert);
24✔
1059
                $caPrivateKey = \phpseclib3\Crypt\PublicKeyLoader::load($caKey);
24✔
1060

1061
                if (!$caPrivateKey instanceof \phpseclib3\Crypt\Common\PrivateKey) {
24✔
1062
                        $this->logger->error('Loaded key is not a private key', ['keyType' => get_class($caPrivateKey)]);
×
1063
                        throw new \RuntimeException('Loaded key is not a private key');
×
1064
                }
1065

1066
                $issuer->setPrivateKey($caPrivateKey);
24✔
1067
                return $issuer;
24✔
1068
        }
1069

1070
        private function createAndSignCrl(\phpseclib3\File\X509 $issuer, array $revokedCertificates, int $crlNumber): array {
1071
                $utcZone = new \DateTimeZone('UTC');
24✔
1072
                $crlToSign = new \phpseclib3\File\X509();
24✔
1073
                $crlToSign->setSerialNumber((string)$crlNumber, 10);
24✔
1074
                $crlToSign->setStartDate(new \DateTime('now', $utcZone));
24✔
1075
                $crlToSign->setEndDate(new \DateTime('+7 days', $utcZone));
24✔
1076

1077
                $initialCrl = $crlToSign->signCRL($issuer, $crlToSign);
24✔
1078
                if ($initialCrl === false) {
24✔
1079
                        $this->logger->error('Failed to create initial CRL structure');
×
1080
                        throw new \RuntimeException('Failed to create initial CRL structure');
×
1081
                }
1082

1083
                if (!empty($revokedCertificates)) {
24✔
1084
                        $savedCrl = $crlToSign->saveCRL($initialCrl);
17✔
1085
                        if ($savedCrl === false) {
17✔
1086
                                $this->logger->error('Failed to save initial CRL structure');
×
1087
                                throw new \RuntimeException('Failed to save initial CRL structure');
×
1088
                        }
1089

1090
                        $crlToSign->loadCRL($savedCrl);
17✔
1091

1092
                        $dateFormat = 'D, d M Y H:i:s O';
17✔
1093
                        foreach ($revokedCertificates as $cert) {
17✔
1094
                                $serialNumber = $cert->getSerialNumber();
17✔
1095
                                $normalizedSerial = ltrim($serialNumber, '0') ?: '0';
17✔
1096
                                $crlToSign->revoke(
17✔
1097
                                        new \phpseclib3\Math\BigInteger($normalizedSerial, 16),
17✔
1098
                                        $cert->getRevokedAt()->format($dateFormat)
17✔
1099
                                );
17✔
1100
                        }
1101

1102
                        $signedCrl = $crlToSign->signCRL($issuer, $crlToSign);
17✔
1103
                } else {
1104
                        $signedCrl = $initialCrl;
8✔
1105
                }
1106

1107
                if ($signedCrl === false) {
24✔
1108
                        $this->logger->error('Failed to sign CRL', ['crlNumber' => $crlNumber]);
×
1109
                        throw new \RuntimeException('Failed to sign CRL');
×
1110
                }
1111

1112
                if (!isset($signedCrl['signatureAlgorithm'])) {
24✔
1113
                        $signedCrl['signatureAlgorithm'] = ['algorithm' => 'sha256WithRSAEncryption'];
×
1114
                }
1115

1116
                return $signedCrl;
24✔
1117
        }
1118

1119
        private function saveCrlToDer(array $signedCrl, string $configPath): string {
1120
                $crlDerPath = $configPath . DIRECTORY_SEPARATOR . 'crl.der';
24✔
1121
                $crlToSign = new \phpseclib3\File\X509();
24✔
1122

1123
                $crlDerData = $crlToSign->saveCRL($signedCrl, \phpseclib3\File\X509::FORMAT_DER);
24✔
1124

1125
                if ($crlDerData === false) {
24✔
1126
                        $this->logger->error('Failed to save CRL in DER format');
×
1127
                        throw new \RuntimeException('Failed to save CRL in DER format');
×
1128
                }
1129

1130
                if (file_put_contents($crlDerPath, $crlDerData) === false) {
24✔
1131
                        $this->logger->error('Failed to write CRL DER file', ['path' => $crlDerPath]);
×
1132
                        throw new \RuntimeException('Failed to write CRL DER file');
×
1133
                }
1134

1135
                return $crlDerData;
24✔
1136
        }
1137

1138
        #[\Override]
1139
        public function validateRootCertificate(): void {
1140
                $configPath = $this->getCurrentConfigPath();
22✔
1141
                if (empty($configPath)) {
22✔
1142
                        return;
×
1143
                }
1144

1145
                if (!is_dir($configPath)) {
22✔
1146
                        return;
1✔
1147
                }
1148

1149
                $rootCertPath = $configPath . DIRECTORY_SEPARATOR . 'ca.pem';
21✔
1150

1151
                if (!file_exists($rootCertPath)) {
21✔
1152
                        return;
2✔
1153
                }
1154

1155
                $rootCert = file_get_contents($rootCertPath);
19✔
1156
                if (empty($rootCert)) {
19✔
1157
                        return;
×
1158
                }
1159

1160
                $certificate = openssl_x509_read($rootCert);
19✔
1161
                if ($certificate === false) {
19✔
1162
                        throw new LibresignException('Invalid root certificate content');
×
1163
                }
1164
                $certInfo = openssl_x509_parse($certificate);
19✔
1165
                if ($certInfo === false) {
19✔
1166
                        throw new LibresignException('Failed to parse root certificate');
×
1167
                }
1168

1169
                if ($this->checkCertificateRevoked($certInfo['serialNumber'])) {
19✔
1170
                        $this->logger->error('Root certificate has been revoked', [
×
1171
                                'ca_id' => $this->getCaId(),
×
1172
                                'impact' => 'all_leaf_certificates_invalid',
×
1173
                        ]);
×
1174
                        throw new LibresignException(
×
1175
                                'Root certificate has been revoked. Please contact the administrator to regenerate the signing certificate.',
×
1176
                                \OC\AppFramework\Http::STATUS_PRECONDITION_FAILED
×
1177
                        );
×
1178
                }
1179

1180
                if ($certInfo['validTo_time_t'] < time()) {
19✔
1181
                        $this->logger->error('Root certificate has expired', [
×
1182
                                'ca_id' => $this->getCaId(),
×
1183
                        ]);
×
1184
                        throw new LibresignException(
×
1185
                                'Root certificate has expired. Please contact the administrator to regenerate the signing certificate.',
×
1186
                                \OC\AppFramework\Http::STATUS_PRECONDITION_FAILED
×
1187
                        );
×
1188
                }
1189

1190
                $remainingDays = $this->calculateRemainingDays($certInfo['validTo_time_t']);
19✔
1191
                $leafExpiryDays = $this->getLeafExpiryInDays();
19✔
1192

1193
                if ($remainingDays <= $leafExpiryDays) {
19✔
1194
                        $this->logger->warning('Root certificate renewal needed', [
6✔
1195
                                'remaining_days' => $remainingDays,
6✔
1196
                                'leaf_expiry_days' => $leafExpiryDays,
6✔
1197
                        ]);
6✔
1198
                }
1199
        }
1200

1201
        private function checkCertificateRevoked(string $serialNumber): bool {
1202
                try {
1203
                        /** @var \OCA\Libresign\Service\CrlService */
1204
                        $crlService = \OC::$server->get(\OCA\Libresign\Service\CrlService::class);
19✔
1205
                        $status = $crlService->getCertificateStatus($serialNumber);
19✔
1206
                        return $status['status'] === 'revoked';
19✔
1207
                } catch (\Exception $e) {
×
1208
                        $this->logger->warning('Failed to check root certificate revocation status', [
×
1209
                                'error' => $e->getMessage()
×
1210
                        ]);
×
1211
                        return false;
×
1212
                }
1213
        }
1214
}
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