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

LibreSign / libresign / 29054982929

09 Jul 2026 10:35PM UTC coverage: 67.466%. First build
29054982929

Pull #7887

github

web-flow
Merge 5460ee058 into e4d7ad4bb
Pull Request #7887: fix(crl): improve CRL generation performance and reliability

38 of 81 new or added lines in 5 files covered. (46.91%)

16098 of 23861 relevant lines covered (67.47%)

10.94 hits per line

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

70.0
/lib/Service/Crl/CrlRevocationChecker.php
1
<?php
2

3
declare(strict_types=1);
4

5
/**
6
 * SPDX-FileCopyrightText: 2026 LibreCode coop and contributors
7
 * SPDX-License-Identifier: AGPL-3.0-or-later
8
 */
9

10
namespace OCA\Libresign\Service\Crl;
11

12
use OCA\Libresign\Enum\CrlValidationStatus;
13
use OCA\Libresign\Service\Crl\Ldap\LdapCrlDownloader;
14
use OCA\Libresign\Service\Policy\PolicyService;
15
use OCA\Libresign\Service\Policy\Provider\CrlValidation\CrlValidationPolicy;
16
use OCP\ICache;
17
use OCP\ICacheFactory;
18
use OCP\IConfig;
19
use OCP\ITempManager;
20
use OCP\IURLGenerator;
21
use Psr\Log\LoggerInterface;
22

23
/**
24
 * Verifies whether a certificate has been revoked by checking its embedded
25
 * CRL Distribution Point URLs. Supports HTTP/HTTPS, LDAP (RFC 4516), and
26
 * local LibreSign-managed CRL endpoints.
27
 */
28
class CrlRevocationChecker {
29
        /** Cached result of {@see getLocalCrlPattern()} — built once per request. */
30
        private ?string $localCrlPattern = null;
31

32
        /** @var array<string, string|null> */
33
        private array $localCrlRequestCache = [];
34

35
        /** @var array<string, string|null> */
36
        private array $parsedCrlTextRequestCache = [];
37

38
        /** @var array<string, array{status: CrlValidationStatus, revoked_at?: string}> */
39
        private array $validationResultRequestCache = [];
40

41
        /** Distributed cache for externally downloaded CRL content (TTL: 24 h). */
42
        private ICache $cache;
43

44
        private const BINARY_CACHE_PREFIX = 'base64:';
45
        private const PARSED_TEXT_CACHE_PREFIX = 'parsed_text:';
46
        private const PARSED_TEXT_CACHE_TTL = 86400;
47

48
        public function __construct(
49
                private IConfig $config,
50
                private PolicyService $policyService,
51
                private IURLGenerator $urlGenerator,
52
                private ITempManager $tempManager,
53
                private LoggerInterface $logger,
54
                ICacheFactory $cacheFactory,
55
                private LdapCrlDownloader $ldapDownloader,
56
        ) {
57
                $this->cache = $cacheFactory->createDistributed('libresign_crl');
101✔
58
        }
59

60
        /**
61
         * Validate a certificate against the CRL Distribution Points found in its
62
         * data. Returns an array with a 'status' key (always {@see CrlValidationStatus})
63
         * and optionally 'revoked_at' (ISO 8601) when the certificate is revoked.
64
         *
65
         * @param string|null $policyUserId Resolve the CRL policy in the context of this user when provided.
66
         * @return array{status: CrlValidationStatus, revoked_at?: string}
67
         */
68
        public function validate(array $crlUrls, string $certPem, ?string $policyUserId = null): array {
69
                return $this->validateFromUrlsWithDetails($crlUrls, $certPem, $policyUserId);
9✔
70
        }
71

72
        /**
73
         * Internal validation worker that iterates through CRL distribution points
74
         * and returns the validation status from the first accessible/conclusive point.
75
         *
76
         * @return array{status: CrlValidationStatus, revoked_at?: string}
77
         */
78
        private function validateFromUrlsWithDetails(array $crlUrls, string $certPem, ?string $policyUserId = null): array {
79
                $normalizedPolicyUserId = is_string($policyUserId) ? trim($policyUserId) : '';
9✔
80
                $externalValidationEnabled = $normalizedPolicyUserId !== ''
9✔
81
                        ? $this->policyService->resolveForUserId(CrlValidationPolicy::KEY, $normalizedPolicyUserId)->getEffectiveValueAsBool(true)
1✔
82
                        : $this->policyService->resolve(CrlValidationPolicy::KEY)->getEffectiveValueAsBool(true);
8✔
83

84
                if (empty($crlUrls)) {
9✔
85
                        // When external validation is disabled, treat an empty distribution-point
86
                        // list the same as if all points were intentionally skipped.
87
                        if (!$externalValidationEnabled) {
3✔
88
                                return ['status' => CrlValidationStatus::DISABLED];
1✔
89
                        }
90
                        return ['status' => CrlValidationStatus::NO_URLS];
2✔
91
                }
92

93
                $accessibleUrls = 0;
6✔
94
                $disabledUrls = 0;
6✔
95
                foreach ($crlUrls as $crlUrl) {
6✔
96
                        try {
97
                                $isLocal = $this->isLocalCrlUrl($crlUrl);
6✔
98
                                // Skip external CRL validation when disabled by admin, but always
99
                                // validate local LibreSign-managed CRLs.
100
                                if (!$externalValidationEnabled && !$isLocal) {
6✔
101
                                        $disabledUrls++;
4✔
102
                                        continue;
4✔
103
                                }
104
                                $validationResult = $this->downloadAndValidateWithDetails($crlUrl, $certPem, $isLocal);
2✔
105
                                if ($validationResult['status'] === CrlValidationStatus::VALID) {
2✔
106
                                        return $validationResult;
×
107
                                }
108
                                if ($validationResult['status'] === CrlValidationStatus::REVOKED) {
2✔
109
                                        return $validationResult;
×
110
                                }
111
                                // Only count as accessible if we actually reached the server and parsed
112
                                // a CRL response – validation_error means the download itself failed.
113
                                if ($validationResult['status'] !== CrlValidationStatus::VALIDATION_ERROR) {
2✔
114
                                        $accessibleUrls++;
2✔
115
                                }
116
                        } catch (\Exception) {
×
117
                                continue;
×
118
                        }
119
                }
120

121
                // All distribution points were intentionally skipped because the admin
122
                // disabled external CRL validation.
123
                if ($disabledUrls > 0 && $accessibleUrls === 0) {
6✔
124
                        return ['status' => CrlValidationStatus::DISABLED];
4✔
125
                }
126

127
                if ($accessibleUrls === 0) {
2✔
128
                        return ['status' => CrlValidationStatus::URLS_INACCESSIBLE];
2✔
129
                }
130

131
                return ['status' => CrlValidationStatus::VALIDATION_FAILED];
×
132
        }
133

134
        /**
135
         * Download and validate CRL content from a single source URL.
136
         *
137
         * @return array{status: CrlValidationStatus, revoked_at?: string}
138
         */
139
        private function downloadAndValidateWithDetails(string $crlUrl, string $certPem, bool $isLocal): array {
140
                try {
141
                        if ($isLocal) {
2✔
142
                                $crlContent = $this->generateLocalCrl($crlUrl);
1✔
143
                        } elseif ($this->ldapDownloader->isLdapUrl($crlUrl)) {
1✔
144
                                $crlContent = $this->ldapDownloader->download($crlUrl);
×
145
                        } else {
146
                                $crlContent = $this->downloadCrlContent($crlUrl);
1✔
147
                        }
148

149
                        if (!$crlContent) {
2✔
150
                                throw new \Exception('Failed to get CRL content');
2✔
151
                        }
152

153
                        return $this->checkCertificateInCrlWithDetails($certPem, $crlContent);
×
154
                } catch (\Exception) {
2✔
155
                        return ['status' => CrlValidationStatus::VALIDATION_ERROR];
2✔
156
                }
157
        }
158

159
        private function isLocalCrlUrl(string $url): bool {
160
                $host = parse_url($url, PHP_URL_HOST);
6✔
161
                if (!$host) {
6✔
162
                        return false;
×
163
                }
164

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

167
                return in_array($host, $trustedDomains, true);
6✔
168
        }
169

170
        protected function generateLocalCrl(string $crlUrl): ?string {
171
                if (array_key_exists($crlUrl, $this->localCrlRequestCache)) {
3✔
172
                        return $this->localCrlRequestCache[$crlUrl];
1✔
173
                }
174

175
                try {
176
                        $pattern = $this->getLocalCrlPattern();
3✔
177
                        if (preg_match($pattern, $crlUrl, $matches)) {
3✔
178
                                $instanceId = $matches[1];
2✔
179
                                $generation = (int)$matches[2];
2✔
180
                                $engineType = $matches[3];
2✔
181

182
                                return $this->localCrlRequestCache[$crlUrl] = $this->getCrlService()->generateCrlDer($instanceId, $generation, $engineType);
2✔
183
                        }
184

185
                        $this->logger->debug('CRL URL does not match expected pattern', ['url' => $crlUrl, 'pattern' => $pattern]);
1✔
186
                        return $this->localCrlRequestCache[$crlUrl] = null;
1✔
187
                } catch (\Exception $e) {
1✔
188
                        if ($e instanceof \RuntimeException && str_starts_with($e->getMessage(), 'Config path does not exist for instanceId:')) {
1✔
189
                                $this->logger->debug('Skipping local CRL generation because source PKI config path is missing', [
×
190
                                        'reason' => $e->getMessage(),
×
191
                                ]);
×
192
                        } elseif ($e instanceof \RuntimeException && str_contains($e->getMessage(), 'already in progress')) {
1✔
193
                                $this->logger->debug('Local CRL generation is already in progress', [
1✔
194
                                        'reason' => $e->getMessage(),
1✔
195
                                ]);
1✔
196
                        } else {
197
                                $this->logger->warning('Failed to generate local CRL: ' . $e->getMessage());
×
198
                        }
199
                        return $this->localCrlRequestCache[$crlUrl] = null;
1✔
200
                }
201
        }
202

203
        /**
204
         * Lazy-loaded to avoid a circular dependency:
205
         * CrlService → CertificateEngineFactory → OpenSslHandler → CrlRevocationChecker → CrlService
206
         */
207
        protected function getCrlService(): CrlService {
208
                /** @var CrlService */
209
                return \OCP\Server::get(CrlService::class);
×
210
        }
211

212
        /**
213
         * Builds and memoises the regex pattern used to recognise local LibreSign
214
         * CRL URLs. The pattern is constructed once per request from the configured
215
         * URL generator and then cached in a property to avoid redundant work on
216
         * installations that validate many certificates in a single request.
217
         */
218
        protected function getLocalCrlPattern(): string {
219
                if ($this->localCrlPattern !== null) {
7✔
220
                        return $this->localCrlPattern;
×
221
                }
222

223
                $templateUrl = $this->urlGenerator->linkToRouteAbsolute('libresign.crl.getRevocationList', [
7✔
224
                        'instanceId' => 'INSTANCEID',
7✔
225
                        'generation' => 999999,
7✔
226
                        'engineType' => 'ENGINETYPE',
7✔
227
                ]);
7✔
228

229
                // Match the path only and accept any scheme/host: the CRL DP host is fixed
230
                // at certificate issuance and may differ from the request host (already
231
                // vetted as trusted by isLocalCrlUrl()).
232
                $templatePath = parse_url($templateUrl, PHP_URL_PATH) ?: $templateUrl;
7✔
233

234
                $patternPath = str_replace('INSTANCEID', '([^/_]+)', $templatePath);
7✔
235
                $patternPath = str_replace('999999', '(\d+)', $patternPath);
7✔
236
                $patternPath = str_replace('ENGINETYPE', '([^/_]+)', $patternPath);
7✔
237

238
                $escapedPattern = str_replace([':', '/', '.'], ['\:', '\/', '\.'], $patternPath);
7✔
239
                $escapedPattern = str_replace('\/apps\/', '(?:\/index\.php)?\/apps\/', $escapedPattern);
7✔
240

241
                $this->localCrlPattern = '/^https?\:\/\/[^\/]+' . $escapedPattern . '$/';
7✔
242
                return $this->localCrlPattern;
7✔
243
        }
244

245
        protected function downloadCrlContent(string $url): ?string {
246
                if (!filter_var($url, FILTER_VALIDATE_URL) || !in_array(parse_url($url, PHP_URL_SCHEME), ['http', 'https'])) {
3✔
247
                        return null;
×
248
                }
249

250
                $cacheKey = sha1($url);
3✔
251
                $cached = $this->cache->get($cacheKey);
3✔
252
                if ($cached !== null) {
3✔
253
                        return is_string($cached) ? $this->decodeCachedBinaryContent($cached) : null;
1✔
254
                }
255

256
                $context = stream_context_create([
2✔
257
                        'http' => [
2✔
258
                                'timeout' => 30,
2✔
259
                                'user_agent' => 'LibreSign/1.0 CRL Validator',
2✔
260
                                'follow_location' => 1,
2✔
261
                                'max_redirects' => 3,
2✔
262
                        ]
2✔
263
                ]);
2✔
264

265
                $content = $this->fetchRemoteCrlContent($url, $context);
2✔
266
                if ($content === false) {
2✔
267
                        return null;
1✔
268
                }
269

270
                $this->cache->set($cacheKey, $this->encodeCacheableBinaryContent($content), 86400);
1✔
271
                return $content;
1✔
272
        }
273

274
        /**
275
         * @param resource $context
276
         */
277
        protected function fetchRemoteCrlContent(string $url, $context): string|false {
278
                return @file_get_contents($url, false, $context);
1✔
279
        }
280

281
        private function encodeCacheableBinaryContent(string $content): string {
282
                return self::BINARY_CACHE_PREFIX . base64_encode($content);
1✔
283
        }
284

285
        private function decodeCachedBinaryContent(string $cachedContent): string {
286
                if (!str_starts_with($cachedContent, self::BINARY_CACHE_PREFIX)) {
1✔
287
                        return $cachedContent;
×
288
                }
289

290
                $decoded = base64_decode(substr($cachedContent, strlen(self::BINARY_CACHE_PREFIX)), true);
1✔
291
                return $decoded === false ? $cachedContent : $decoded;
1✔
292
        }
293

294
        protected function isSerialNumberInCrl(string $crlText, string $serialNumber): bool {
295
                $normalizedSerial = strtoupper($serialNumber);
7✔
296
                $normalizedSerial = ltrim($normalizedSerial, '0') ?: '0';
7✔
297

298
                return preg_match('/Serial Number: 0*' . preg_quote($normalizedSerial, '/') . '/', $crlText) === 1;
7✔
299
        }
300

301
        /**
302
         * Check if certificate serial is revoked in the provided CRL content.
303
         *
304
         * @return array{status: CrlValidationStatus, revoked_at?: string}
305
         */
306
        private function checkCertificateInCrlWithDetails(string $certPem, string $crlContent): array {
307
                try {
308
                        $certResource = openssl_x509_read($certPem);
×
309
                        if (!$certResource) {
×
310
                                return ['status' => CrlValidationStatus::VALIDATION_ERROR];
×
311
                        }
312

313
                        $certData = openssl_x509_parse($certResource);
×
314
                        if (!isset($certData['serialNumber'])) {
×
315
                                return ['status' => CrlValidationStatus::VALIDATION_ERROR];
×
316
                        }
317

NEW
318
                        $serialCandidates = [$certData['serialNumber']];
×
NEW
319
                        if (!empty($certData['serialNumberHex'])) {
×
NEW
320
                                $serialCandidates[] = $certData['serialNumberHex'];
×
321
                        }
322

NEW
323
                        $validationCacheKey = $this->buildValidationCacheKey($serialCandidates, $crlContent);
×
NEW
324
                        if (array_key_exists($validationCacheKey, $this->validationResultRequestCache)) {
×
NEW
325
                                return $this->validationResultRequestCache[$validationCacheKey];
×
326
                        }
327

NEW
328
                        $crlText = $this->getParsedCrlText($crlContent);
×
NEW
329
                        if ($crlText === null) {
×
NEW
330
                                return $this->validationResultRequestCache[$validationCacheKey] = ['status' => CrlValidationStatus::VALIDATION_ERROR];
×
331
                        }
332

NEW
333
                        foreach ($serialCandidates as $serial) {
×
NEW
334
                                if ($this->isSerialNumberInCrl($crlText, $serial)) {
×
NEW
335
                                        $revokedAt = $this->extractRevocationDateFromCrlText($crlText, $serialCandidates);
×
NEW
336
                                        return $this->validationResultRequestCache[$validationCacheKey] = array_filter([
×
NEW
337
                                                'status' => CrlValidationStatus::REVOKED,
×
NEW
338
                                                'revoked_at' => $revokedAt,
×
NEW
339
                                        ]);
×
340
                                }
341
                        }
342

NEW
343
                        return $this->validationResultRequestCache[$validationCacheKey] = ['status' => CrlValidationStatus::VALID];
×
344
                } catch (\Exception) {
×
345
                        return ['status' => CrlValidationStatus::VALIDATION_ERROR];
×
346
                }
347
        }
348

349
        protected function getParsedCrlText(string $crlContent): ?string {
350
                $contentHash = sha1($crlContent);
2✔
351

352
                if (array_key_exists($contentHash, $this->parsedCrlTextRequestCache)) {
2✔
353
                        return $this->parsedCrlTextRequestCache[$contentHash];
1✔
354
                }
355

356
                $cacheKey = self::PARSED_TEXT_CACHE_PREFIX . $contentHash;
2✔
357
                $cached = $this->cache->get($cacheKey);
2✔
358
                if ($cached !== null) {
2✔
359
                        return $this->parsedCrlTextRequestCache[$contentHash] = is_string($cached) ? $cached : null;
1✔
360
                }
361

362
                $tempFile = $this->tempManager->getTemporaryFile('.der');
1✔
363
                if (!file_put_contents($tempFile, $crlContent)) {
1✔
NEW
364
                        return $this->parsedCrlTextRequestCache[$contentHash] = null;
×
365
                }
366

367
                [$output, $exitCode] = $this->execOpenSslCrl($tempFile);
1✔
368

369
                if ($exitCode !== 0 || empty($output)) {
1✔
NEW
370
                        return $this->parsedCrlTextRequestCache[$contentHash] = null;
×
371
                }
372

373
                $crlText = implode("\n", $output);
1✔
374
                $this->cache->set($cacheKey, $crlText, self::PARSED_TEXT_CACHE_TTL);
1✔
375
                return $this->parsedCrlTextRequestCache[$contentHash] = $crlText;
1✔
376
        }
377

378
        protected function buildValidationCacheKey(array $serialCandidates, string $crlContent): string {
379
                return sha1(implode(',', $serialCandidates) . ':' . sha1($crlContent));
3✔
380
        }
381

382
        /**
383
         * Runs `openssl crl -text -noout` on the given DER file and returns
384
         * [output_lines[], exit_code]. Extracted to allow test subclasses to
385
         * override it without executing a real process.
386
         */
387
        protected function execOpenSslCrl(string $tempCrlFile): array {
388
                $cmd = sprintf(
×
389
                        'openssl crl -in %s -inform DER -text -noout',
×
390
                        escapeshellarg($tempCrlFile)
×
391
                );
×
392
                exec($cmd, $output, $exitCode);
×
393
                return [$output, $exitCode];
×
394
        }
395

396
        protected function extractRevocationDateFromCrlText(string $crlText, array $serialNumbers): ?string {
397
                foreach ($serialNumbers as $serial) {
3✔
398
                        $normalizedSerial = strtoupper(ltrim((string)$serial, '0')) ?: '0';
3✔
399
                        $pattern = '/Serial Number:\s*0*' . preg_quote($normalizedSerial, '/') . '\s*\R\s*Revocation Date:\s*([^\r\n]+)/i';
3✔
400
                        if (preg_match($pattern, $crlText, $matches) !== 1) {
3✔
401
                                continue;
1✔
402
                        }
403
                        $dateText = trim($matches[1]);
2✔
404
                        try {
405
                                $date = new \DateTimeImmutable($dateText, new \DateTimeZone('UTC'));
2✔
406
                                return $date->setTimezone(new \DateTimeZone('UTC'))->format(\DateTimeInterface::ATOM);
2✔
407
                        } catch (\Exception) {
×
408
                                continue;
×
409
                        }
410
                }
411
                return null;
1✔
412
        }
413
}
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