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

LibreSign / libresign / 19044351794

03 Nov 2025 05:56PM UTC coverage: 39.63%. First build
19044351794

Pull #5731

github

web-flow
Merge bd7db4b6e into e77ceb5b6
Pull Request #5731: feat: add multiple ou

33 of 93 new or added lines in 8 files covered. (35.48%)

4569 of 11529 relevant lines covered (39.63%)

2.95 hits per line

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

44.6
/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\CertificatePolicyService;
18
use OCP\Files\AppData\IAppDataFactory;
19
use OCP\Files\IAppData;
20
use OCP\Files\SimpleFS\ISimpleFolder;
21
use OCP\IAppConfig;
22
use OCP\IConfig;
23
use OCP\IDateTimeFormatter;
24
use OCP\ITempManager;
25
use OCP\IURLGenerator;
26
use OpenSSLAsymmetricKey;
27
use OpenSSLCertificate;
28
use ReflectionClass;
29

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

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

71
        public function __construct(
72
                protected IConfig $config,
73
                protected IAppConfig $appConfig,
74
                protected IAppDataFactory $appDataFactory,
75
                protected IDateTimeFormatter $dateTimeFormatter,
76
                protected ITempManager $tempManager,
77
                protected CertificatePolicyService $certificatePolicyService,
78
                protected IURLGenerator $urlGenerator,
79
        ) {
80
                $this->appData = $appDataFactory->get('libresign');
64✔
81
        }
82

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

107
                return $certContent;
7✔
108
        }
109

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

121
        #[\Override]
122
        public function readCertificate(string $certificate, string $privateKey): array {
123
                if (empty($certificate) || empty($privateKey)) {
9✔
124
                        throw new EmptyCertificateException();
1✔
125
                }
126
                $certContent = $this->opensslPkcs12Read($certificate, $privateKey);
8✔
127

128
                $return = $this->parseX509($certContent['cert']);
6✔
129
                if (isset($certContent['extracerts'])) {
6✔
130
                        foreach ($certContent['extracerts'] as $extraCert) {
6✔
131
                                $return['extracerts'][] = $this->parseX509($extraCert);
6✔
132
                        }
133
                        $return['extracerts'] = $this->orderCertificates($return['extracerts']);
6✔
134
                }
135
                return $return;
6✔
136
        }
137

138
        private function parseX509(string $x509): array {
139
                $parsed = openssl_x509_parse(openssl_x509_read($x509));
6✔
140

141
                $return = self::convertArrayToUtf8($parsed);
6✔
142

143
                if (isset($return['subject']['OU']) && is_string($return['subject']['OU'])) {
6✔
144
                        if (str_contains($return['subject']['OU'], '|')) {
2✔
NEW
145
                                $return['subject']['OU'] = explode('|', $return['subject']['OU']);
×
146
                        } else {
147
                                $return['subject']['OU'] = [$return['subject']['OU']];
2✔
148
                        }
149
                }
150

151
                $return['valid_from'] = $this->dateTimeFormatter->formatDateTime($parsed['validFrom_time_t']);
6✔
152
                $return['valid_to'] = $this->dateTimeFormatter->formatDateTime($parsed['validTo_time_t']);
6✔
153
                return $return;
6✔
154
        }
155

156
        private static function convertArrayToUtf8($array) {
157
                foreach ($array as $key => $value) {
6✔
158
                        if (is_array($value)) {
6✔
159
                                $array[$key] = self::convertArrayToUtf8($value);
6✔
160
                        } elseif (is_string($value)) {
6✔
161
                                $array[$key] = mb_convert_encoding($value, 'UTF-8', 'UTF-8');
6✔
162
                        }
163
                }
164
                return $array;
6✔
165
        }
166

167
        public function opensslPkcs12Read(string &$certificate, string $privateKey): array {
168
                openssl_pkcs12_read($certificate, $certContent, $privateKey);
8✔
169
                if (!empty($certContent)) {
8✔
170
                        return $certContent;
6✔
171
                }
172
                /**
173
                 * Reference:
174
                 *
175
                 * https://github.com/php/php-src/issues/12128
176
                 * https://www.php.net/manual/en/function.openssl-pkcs12-read.php#128992
177
                 */
178
                $msg = openssl_error_string();
2✔
179
                if ($msg === 'error:0308010C:digital envelope routines::unsupported') {
2✔
180
                        $tempPassword = $this->tempManager->getTemporaryFile();
×
181
                        $tempEncriptedOriginal = $this->tempManager->getTemporaryFile();
×
182
                        $tempEncriptedRepacked = $this->tempManager->getTemporaryFile();
×
183
                        $tempDecrypted = $this->tempManager->getTemporaryFile();
×
184
                        file_put_contents($tempPassword, $privateKey);
×
185
                        file_put_contents($tempEncriptedOriginal, $certificate);
×
186
                        shell_exec(<<<REPACK_COMMAND
×
187
                                cat $tempPassword | openssl pkcs12 -legacy -in $tempEncriptedOriginal -nodes -out $tempDecrypted -passin stdin &&
×
188
                                cat $tempPassword | openssl pkcs12 -in $tempDecrypted -export -out $tempEncriptedRepacked -passout stdin
×
189
                                REPACK_COMMAND
×
190
                        );
×
191
                        $certificateRepacked = file_get_contents($tempEncriptedRepacked);
×
192
                        openssl_pkcs12_read($certificateRepacked, $certContent, $privateKey);
×
193
                        if (!empty($certContent)) {
×
194
                                $certificate = $certificateRepacked;
×
195
                                return $certContent;
×
196
                        }
197
                }
198
                throw new InvalidPasswordException();
2✔
199
        }
200

201
        /**
202
         * @param (int|string) $name
203
         *
204
         * @psalm-param array-key $name
205
         */
206
        public function translateToLong($name): string {
207
                return match ($name) {
3✔
208
                        'CN' => 'CommonName',
×
209
                        'C' => 'Country',
3✔
210
                        'ST' => 'State',
×
211
                        'L' => 'Locality',
×
212
                        'O' => 'Organization',
3✔
213
                        'OU' => 'OrganizationalUnit',
2✔
214
                        'UID' => 'UserIdentifier',
×
215
                        default => '',
3✔
216
                };
3✔
217
        }
218

219
        public function setEngine(string $engine): void {
220
                $this->appConfig->setValueString(Application::APP_ID, 'certificate_engine', $engine);
×
221
                $this->engine = $engine;
×
222
        }
223

224
        #[\Override]
225
        public function getEngine(): string {
226
                if ($this->engine) {
×
227
                        return $this->engine;
×
228
                }
229
                $this->engine = $this->appConfig->getValueString(Application::APP_ID, 'certificate_engine', 'openssl');
×
230
                return $this->engine;
×
231
        }
232

233
        #[\Override]
234
        public function populateInstance(array $rootCert): IEngineHandler {
235
                if (empty($rootCert)) {
8✔
236
                        $rootCert = $this->appConfig->getValueArray(Application::APP_ID, 'rootCert');
8✔
237
                }
238
                if (!$rootCert) {
8✔
239
                        return $this;
8✔
240
                }
241
                if (!empty($rootCert['names'])) {
×
242
                        foreach ($rootCert['names'] as $id => $customName) {
×
243
                                $longCustomName = $this->translateToLong($id);
×
244
                                // Prevent to save a property that don't exists
245
                                if (!property_exists($this, lcfirst($longCustomName))) {
×
246
                                        continue;
×
247
                                }
248
                                $this->{'set' . ucfirst($longCustomName)}($customName['value']);
×
249
                        }
250
                }
251
                if (!$this->getCommonName()) {
×
252
                        $this->setCommonName($rootCert['commonName']);
×
253
                }
254
                return $this;
×
255
        }
256

257
        #[\Override]
258
        public function getConfigPath(): string {
259
                if ($this->configPath) {
16✔
260
                        return $this->configPath;
15✔
261
                }
262
                $this->configPath = $this->appConfig->getValueString(Application::APP_ID, 'config_path');
16✔
263
                if ($this->configPath
16✔
264
                        && str_ends_with($this->configPath, $this->getName() . '_config')
16✔
265
                        && is_dir($this->configPath)
16✔
266
                ) {
267
                        return $this->configPath;
10✔
268
                }
269
                try {
270
                        $folder = $this->appData->getFolder($this->getName() . '_config');
7✔
271
                        if (!$folder->fileExists('/')) {
7✔
272
                                throw new \Exception();
7✔
273
                        }
274
                } catch (\Throwable) {
×
275
                        $folder = $this->appData->newFolder($this->getName() . '_config');
×
276
                }
277
                $dataDir = $this->config->getSystemValue('datadirectory', \OC::$SERVERROOT . '/data/');
7✔
278
                $this->configPath = $dataDir . '/' . $this->getInternalPathOfFolder($folder);
7✔
279
                if (!is_dir($this->configPath)) {
7✔
280
                        $currentFile = realpath(__DIR__);
7✔
281
                        $owner = posix_getpwuid(fileowner($currentFile));
7✔
282
                        $fullCommand = 'mkdir -p "' . $this->configPath . '"';
7✔
283
                        if (posix_getuid() !== $owner['uid']) {
7✔
284
                                $fullCommand = 'runuser -u ' . $owner['name'] . ' -- ' . $fullCommand;
×
285
                        }
286
                        exec($fullCommand);
7✔
287
                }
288
                return $this->configPath;
7✔
289
        }
290

291
        /**
292
         * @todo check a best solution to don't use reflection
293
         */
294
        private function getInternalPathOfFolder(ISimpleFolder $node): string {
295
                $reflection = new \ReflectionClass($node);
7✔
296
                $reflectionProperty = $reflection->getProperty('folder');
7✔
297
                $folder = $reflectionProperty->getValue($node);
7✔
298
                $path = $folder->getInternalPath();
7✔
299
                return $path;
7✔
300
        }
301

302
        #[\Override]
303
        public function setConfigPath(string $configPath): IEngineHandler {
304
                if (!$configPath) {
×
305
                        $this->appConfig->deleteKey(Application::APP_ID, 'config_path');
×
306
                } else {
307
                        if (!is_dir($configPath)) {
×
308
                                mkdir(
×
309
                                        directory: $configPath,
×
310
                                        recursive: true,
×
311
                                );
×
312
                        }
313
                        $this->appConfig->setValueString(Application::APP_ID, 'config_path', $configPath);
×
314
                }
315
                $this->configPath = $configPath;
×
316
                return $this;
×
317
        }
318

319
        public function getName(): string {
320
                $reflect = new ReflectionClass($this);
16✔
321
                $className = $reflect->getShortName();
16✔
322
                $name = strtolower(substr($className, 0, -7));
16✔
323
                return $name;
16✔
324
        }
325

326
        protected function getNames(): array {
327
                $names = [
15✔
328
                        'C' => $this->getCountry(),
15✔
329
                        'ST' => $this->getState(),
15✔
330
                        'L' => $this->getLocality(),
15✔
331
                        'O' => $this->getOrganization(),
15✔
332
                        'OU' => $this->getOrganizationalUnit(),
15✔
333
                ];
15✔
334
                if ($uid = $this->getUID()) {
15✔
335
                        $names['UID'] = $uid;
×
336
                }
337
                $names = array_filter($names, fn ($v) => !empty($v));
15✔
338
                return $names;
15✔
339
        }
340

341
        public function getUID(): string {
342
                return str_replace(' ', '+', $this->UID);
15✔
343
        }
344

345
        #[\Override]
346
        public function getLeafExpiryInDays(): int {
347
                $exp = $this->appConfig->getValueInt(Application::APP_ID, 'expiry_in_days', 365);
7✔
348
                return $exp > 0 ? $exp : 365;
7✔
349
        }
350

351
        #[\Override]
352
        public function getCaExpiryInDays(): int {
353
                $exp = $this->appConfig->getValueInt(Application::APP_ID, 'ca_expiry_in_days', 3650); // 10 years
14✔
354
                return $exp > 0 ? $exp : 3650;
14✔
355
        }
356

357
        private function getCertificatePolicy(): array {
358
                $return = ['policySection' => []];
1✔
359
                $oid = $this->certificatePolicyService->getOid();
1✔
360
                $cps = $this->certificatePolicyService->getCps();
1✔
361
                if ($oid && $cps) {
1✔
362
                        $return['policySection'][] = [
×
363
                                'OID' => $oid,
×
364
                                'CPS' => $cps,
×
365
                        ];
×
366
                }
367
                return $return;
1✔
368
        }
369

370
        abstract protected function getConfigureCheckResourceName(): string;
371

372
        abstract protected function getCertificateRegenerationTip(): string;
373

374
        abstract protected function getEngineSpecificChecks(): array;
375

376
        abstract protected function getSetupSuccessMessage(): string;
377

378
        abstract protected function getSetupErrorMessage(): string;
379

380
        abstract protected function getSetupErrorTip(): string;
381

382
        #[\Override]
383
        public function configureCheck(): array {
384
                $checks = $this->getEngineSpecificChecks();
1✔
385

386
                if (!$this->isSetupOk()) {
1✔
387
                        return array_merge($checks, [
1✔
388
                                (new ConfigureCheckHelper())
1✔
389
                                        ->setErrorMessage($this->getSetupErrorMessage())
1✔
390
                                        ->setResource($this->getConfigureCheckResourceName())
1✔
391
                                        ->setTip($this->getSetupErrorTip())
1✔
392
                        ]);
1✔
393
                }
394

395
                $checks[] = (new ConfigureCheckHelper())
×
396
                        ->setSuccessMessage($this->getSetupSuccessMessage())
×
397
                        ->setResource($this->getConfigureCheckResourceName());
×
398

399
                $modernFeaturesCheck = $this->checkRootCertificateModernFeatures();
×
400
                if ($modernFeaturesCheck) {
×
401
                        $checks[] = $modernFeaturesCheck;
×
402
                }
403

404
                return $checks;
×
405
        }
406

407
        protected function checkRootCertificateModernFeatures(): ?ConfigureCheckHelper {
408
                $configPath = $this->getConfigPath();
×
409
                $caCertPath = $configPath . DIRECTORY_SEPARATOR . 'ca.pem';
×
410

411
                try {
412
                        $certContent = file_get_contents($caCertPath);
×
413
                        if (!$certContent) {
×
414
                                return (new ConfigureCheckHelper())
×
415
                                        ->setErrorMessage('Failed to read root certificate file')
×
416
                                        ->setResource($this->getConfigureCheckResourceName())
×
417
                                        ->setTip('Check file permissions and disk space');
×
418
                        }
419

420
                        $x509Resource = openssl_x509_read($certContent);
×
421
                        if (!$x509Resource) {
×
422
                                return (new ConfigureCheckHelper())
×
423
                                        ->setErrorMessage('Failed to parse root certificate')
×
424
                                        ->setResource($this->getConfigureCheckResourceName())
×
425
                                        ->setTip('Root certificate file may be corrupted or invalid');
×
426
                        }
427

428
                        $parsed = openssl_x509_parse($x509Resource);
×
429
                        if (!$parsed) {
×
430
                                return (new ConfigureCheckHelper())
×
431
                                        ->setErrorMessage('Failed to extract root certificate information')
×
432
                                        ->setResource($this->getConfigureCheckResourceName())
×
433
                                        ->setTip('Root certificate may be in an unsupported format');
×
434
                        }
435

436
                        $criticalIssues = [];
×
437
                        $minorIssues = [];
×
438

439
                        if (isset($parsed['serialNumber'])) {
×
440
                                $serialNumber = $parsed['serialNumber'];
×
441
                                $serialDecimal = hexdec($serialNumber);
×
442
                                if ($serialDecimal <= 1) {
×
443
                                        $minorIssues[] = 'Serial number is simple (zero or one)';
×
444
                                }
445
                        } else {
446
                                $criticalIssues[] = 'Serial number is missing';
×
447
                        }
448

449
                        $missingExtensions = [];
×
450
                        if (!isset($parsed['extensions']['subjectKeyIdentifier'])) {
×
451
                                $missingExtensions[] = 'Subject Key Identifier (SKI)';
×
452
                        }
453

454
                        $isSelfSigned = (isset($parsed['issuer']) && isset($parsed['subject'])
×
455
                                                        && $parsed['issuer'] === $parsed['subject']);
×
456

457
                        /**
458
                         * @todo workarround for missing AKI at certificates generated by CFSSL.
459
                         *
460
                         * CFSSL does not add Authority Key Identifier (AKI) to self-signed root certificates.
461
                         */
462
                        if (!$isSelfSigned && !isset($parsed['extensions']['authorityKeyIdentifier'])) {
×
463
                                $missingExtensions[] = 'Authority Key Identifier (AKI)';
×
464
                        }
465

466
                        if (!isset($parsed['extensions']['crlDistributionPoints'])) {
×
467
                                $missingExtensions[] = 'CRL Distribution Points';
×
468
                        }
469

470
                        if (!empty($missingExtensions)) {
×
471
                                $extensionsList = implode(', ', $missingExtensions);
×
472
                                $minorIssues[] = "Missing modern extensions: {$extensionsList}";
×
473
                        }
474

NEW
475
                        $hasLibresignCaUuid = $this->validateLibresignCaUuidInCertificate($parsed);
×
NEW
476
                        if (!$hasLibresignCaUuid) {
×
NEW
477
                                $minorIssues[] = 'LibreSign CA UUID not found in Organizational Unit';
×
478
                        }
479

480
                        if (!empty($criticalIssues)) {
×
481
                                $issuesList = implode(', ', $criticalIssues);
×
482
                                return (new ConfigureCheckHelper())
×
483
                                        ->setErrorMessage("Root certificate has critical issues: {$issuesList}")
×
484
                                        ->setResource($this->getConfigureCheckResourceName())
×
485
                                        ->setTip($this->getCertificateRegenerationTip());
×
486
                        }
487

488
                        if (!empty($minorIssues)) {
×
489
                                $issuesList = implode(', ', $minorIssues);
×
490
                                return (new ConfigureCheckHelper())
×
491
                                        ->setInfoMessage("Root certificate could benefit from modern features: {$issuesList}")
×
492
                                        ->setResource($this->getConfigureCheckResourceName())
×
493
                                        ->setTip($this->getCertificateRegenerationTip() . ' (recommended but not required)');
×
494
                        }
495

496
                        return null;
×
497

498
                } catch (\Exception $e) {
×
499
                        return (new ConfigureCheckHelper())
×
500
                                ->setErrorMessage('Failed to analyze root certificate: ' . $e->getMessage())
×
501
                                ->setResource($this->getConfigureCheckResourceName())
×
502
                                ->setTip('Check if the root certificate file is valid');
×
503
                }
504
        }
505

506
        private function validateLibresignCaUuidInCertificate(array $parsed): bool {
NEW
507
                if (!isset($parsed['subject']['OU'])) {
×
NEW
508
                        return false;
×
509
                }
510

NEW
511
                $instanceId = $this->getInstanceId();
×
NEW
512
                if (empty($instanceId)) {
×
NEW
513
                        return false;
×
514
                }
515

NEW
516
                $organizationalUnits = $parsed['subject']['OU'];
×
517

NEW
518
                if (is_string($organizationalUnits)) {
×
NEW
519
                        if (str_contains($organizationalUnits, '|')) {
×
NEW
520
                                $organizationalUnits = explode('|', $organizationalUnits);
×
521
                        } else {
NEW
522
                                $organizationalUnits = [$organizationalUnits];
×
523
                        }
524
                }
525

NEW
526
                $expectedCaUuid = 'libresign-ca-uuid:' . $instanceId;
×
527

NEW
528
                foreach ($organizationalUnits as $ou) {
×
NEW
529
                        if (trim($ou) === $expectedCaUuid) {
×
NEW
530
                                return true;
×
531
                        }
532
                }
533

NEW
534
                return false;
×
535
        }
536

537
        private function getInstanceId(): string {
NEW
538
                $instanceId = $this->appConfig->getValueString(Application::APP_ID, 'instance_id', '');
×
NEW
539
                if (strlen($instanceId) === 10) {
×
NEW
540
                        return $instanceId;
×
541
                }
NEW
542
                return '';
×
543
        }
544

545
        #[\Override]
546
        public function toArray(): array {
547
                $return = [
1✔
548
                        'configPath' => $this->getConfigPath(),
1✔
549
                        'generated' => $this->isSetupOk(),
1✔
550
                        'rootCert' => [
1✔
551
                                'commonName' => $this->getCommonName(),
1✔
552
                                'names' => [],
1✔
553
                        ],
1✔
554
                ];
1✔
555
                $return = array_merge(
1✔
556
                        $return,
1✔
557
                        $this->getCertificatePolicy(),
1✔
558
                );
1✔
559
                $names = $this->getNames();
1✔
560
                foreach ($names as $name => $value) {
1✔
561
                        $return['rootCert']['names'][] = [
×
562
                                'id' => $name,
×
563
                                'value' => $value,
×
564
                        ];
×
565
                }
566
                return $return;
1✔
567
        }
568

569
        protected function getCrlDistributionUrl(): string {
570
                return $this->urlGenerator->linkToRouteAbsolute('libresign.crl.getRevocationList');
14✔
571
        }
572
}
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