Code Coverage
 
Lines
Functions and Methods
Classes and Traits
Total
72.82% covered (warning)
72.82%
142 / 195
31.58% covered (danger)
31.58%
6 / 19
CRAP
0.00% covered (danger)
0.00%
0 / 1
CrlRevocationChecker
72.82% covered (warning)
72.82%
142 / 195
31.58% covered (danger)
31.58%
6 / 19
208.50
0.00% covered (danger)
0.00%
0 / 1
 __construct
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 validate
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 validateFromUrlsWithDetails
85.71% covered (warning)
85.71%
30 / 35
0.00% covered (danger)
0.00%
0 / 1
20.05
 downloadAndValidateWithDetails
80.00% covered (warning)
80.00%
8 / 10
0.00% covered (danger)
0.00%
0 / 1
5.20
 isLegacyLocalCrlUrl
84.21% covered (warning)
84.21%
16 / 19
0.00% covered (danger)
0.00%
0 / 1
5.10
 isLocalCrlUrl
80.00% covered (warning)
80.00%
4 / 5
0.00% covered (danger)
0.00%
0 / 1
2.03
 generateLocalCrl
85.71% covered (warning)
85.71%
18 / 21
0.00% covered (danger)
0.00%
0 / 1
7.14
 getCrlService
0.00% covered (danger)
0.00%
0 / 1
0.00% covered (danger)
0.00%
0 / 1
2
 getLocalCrlPattern
93.33% covered (success)
93.33%
14 / 15
0.00% covered (danger)
0.00%
0 / 1
3.00
 downloadCrlContent
94.74% covered (success)
94.74%
18 / 19
0.00% covered (danger)
0.00%
0 / 1
6.01
 fetchRemoteCrlContent
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 encodeCacheableBinaryContent
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 decodeCachedBinaryContent
75.00% covered (warning)
75.00%
3 / 4
0.00% covered (danger)
0.00%
0 / 1
3.14
 isSerialNumberInCrl
100.00% covered (success)
100.00%
3 / 3
100.00% covered (success)
100.00%
1 / 1
2
 checkCertificateInCrlWithDetails
0.00% covered (danger)
0.00%
0 / 25
0.00% covered (danger)
0.00%
0 / 1
90
 getParsedCrlText
87.50% covered (warning)
87.50%
14 / 16
0.00% covered (danger)
0.00%
0 / 1
7.10
 buildValidationCacheKey
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 execOpenSslCrl
0.00% covered (danger)
0.00%
0 / 6
0.00% covered (danger)
0.00%
0 / 1
2
 extractRevocationDateFromCrlText
81.82% covered (warning)
81.82%
9 / 11
0.00% covered (danger)
0.00%
0 / 1
5.15
1<?php
2
3declare(strict_types=1);
4
5/**
6 * SPDX-FileCopyrightText: 2026 LibreCode coop and contributors
7 * SPDX-License-Identifier: AGPL-3.0-or-later
8 */
9
10namespace OCA\Libresign\Service\Crl;
11
12use OCA\Libresign\Enum\CrlValidationStatus;
13use OCA\Libresign\Exception\CrlGenerationInProgressException;
14use OCA\Libresign\Service\Crl\Ldap\LdapCrlDownloader;
15use OCA\Libresign\Service\Policy\PolicyService;
16use OCA\Libresign\Service\Policy\Provider\CrlValidation\CrlValidationPolicy;
17use OCP\ICache;
18use OCP\ICacheFactory;
19use OCP\IConfig;
20use OCP\ITempManager;
21use OCP\IURLGenerator;
22use Psr\Log\LoggerInterface;
23
24/**
25 * Verifies whether a certificate has been revoked by checking its embedded
26 * CRL Distribution Point URLs. Supports HTTP/HTTPS, LDAP (RFC 4516), and
27 * local LibreSign-managed CRL endpoints.
28 */
29class CrlRevocationChecker {
30    /** Cached result of {@see getLocalCrlPattern()} — built once per request. */
31    private ?string $localCrlPattern = null;
32
33    /** @var array<string, string|null> */
34    private array $localCrlRequestCache = [];
35
36    /** @var array<string, string|null> */
37    private array $parsedCrlTextRequestCache = [];
38
39    /** @var array<string, array{status: CrlValidationStatus, revoked_at?: string}> */
40    private array $validationResultRequestCache = [];
41
42    /** Distributed cache for externally downloaded CRL content (TTL: 24 h). */
43    private ICache $cache;
44
45    private const BINARY_CACHE_PREFIX = 'base64:';
46    private const PARSED_TEXT_CACHE_PREFIX = 'parsed_text:';
47    private const PARSED_TEXT_CACHE_TTL = 86400;
48
49    public function __construct(
50        private IConfig $config,
51        private PolicyService $policyService,
52        private IURLGenerator $urlGenerator,
53        private ITempManager $tempManager,
54        private LoggerInterface $logger,
55        ICacheFactory $cacheFactory,
56        private LdapCrlDownloader $ldapDownloader,
57    ) {
58        $this->cache = $cacheFactory->createDistributed('libresign_crl');
59    }
60
61    /**
62     * Validate a certificate against the CRL Distribution Points found in its
63     * data. Returns an array with a 'status' key (always {@see CrlValidationStatus})
64     * and optionally 'revoked_at' (ISO 8601) when the certificate is revoked.
65     *
66     * @param string|null $policyUserId Resolve the CRL policy in the context of this user when provided.
67     * @return array{status: CrlValidationStatus, revoked_at?: string}
68     */
69    public function validate(array $crlUrls, string $certPem, ?string $policyUserId = null): array {
70        return $this->validateFromUrlsWithDetails($crlUrls, $certPem, $policyUserId);
71    }
72
73    /**
74     * Internal validation worker that iterates through CRL distribution points
75     * and returns the validation status from the first accessible/conclusive point.
76     *
77     * @return array{status: CrlValidationStatus, revoked_at?: string}
78     */
79    private function validateFromUrlsWithDetails(array $crlUrls, string $certPem, ?string $policyUserId = null): array {
80        $normalizedPolicyUserId = is_string($policyUserId) ? trim($policyUserId) : '';
81        $externalValidationEnabled = $normalizedPolicyUserId !== ''
82            ? $this->policyService->resolveForUserId(CrlValidationPolicy::KEY, $normalizedPolicyUserId)->getEffectiveValueAsBool(true)
83            : $this->policyService->resolve(CrlValidationPolicy::KEY)->getEffectiveValueAsBool(true);
84
85        if (empty($crlUrls)) {
86            // When external validation is disabled, treat an empty distribution-point
87            // list the same as if all points were intentionally skipped.
88            if (!$externalValidationEnabled) {
89                return ['status' => CrlValidationStatus::DISABLED];
90            }
91            return ['status' => CrlValidationStatus::NO_URLS];
92        }
93
94        $accessibleUrls = 0;
95        $disabledUrls = 0;
96        $legacyDistributionPoints = 0;
97        foreach ($crlUrls as $crlUrl) {
98            try {
99                $isLocal = $this->isLocalCrlUrl($crlUrl);
100                if ($isLocal && $this->isLegacyLocalCrlUrl($crlUrl)) {
101                    $legacyDistributionPoints++;
102                    continue;
103                }
104                // Skip external CRL validation when disabled by admin, but always
105                // validate local LibreSign-managed CRLs.
106                if (!$externalValidationEnabled && !$isLocal) {
107                    $disabledUrls++;
108                    continue;
109                }
110                $validationResult = $this->downloadAndValidateWithDetails($crlUrl, $certPem, $isLocal);
111                if ($validationResult['status'] === CrlValidationStatus::VALID) {
112                    return $validationResult;
113                }
114                if ($validationResult['status'] === CrlValidationStatus::REVOKED) {
115                    return $validationResult;
116                }
117                // Only count as accessible if we actually reached the server and parsed
118                // a CRL response – validation_error means the download itself failed.
119                if ($validationResult['status'] !== CrlValidationStatus::VALIDATION_ERROR) {
120                    $accessibleUrls++;
121                }
122            } catch (\Exception) {
123                continue;
124            }
125        }
126
127        if ($legacyDistributionPoints > 0 && $accessibleUrls === 0) {
128            return ['status' => CrlValidationStatus::LEGACY_DISTRIBUTION_POINT];
129        }
130
131        // All distribution points were intentionally skipped because the admin
132        // disabled external CRL validation.
133        if ($disabledUrls > 0 && $accessibleUrls === 0) {
134            return ['status' => CrlValidationStatus::DISABLED];
135        }
136
137        if ($accessibleUrls === 0) {
138            return ['status' => CrlValidationStatus::URLS_INACCESSIBLE];
139        }
140
141        return ['status' => CrlValidationStatus::VALIDATION_FAILED];
142    }
143
144    /**
145     * Download and validate CRL content from a single source URL.
146     *
147     * @return array{status: CrlValidationStatus, revoked_at?: string}
148     */
149    private function downloadAndValidateWithDetails(string $crlUrl, string $certPem, bool $isLocal): array {
150        try {
151            if ($isLocal) {
152                $crlContent = $this->generateLocalCrl($crlUrl);
153            } elseif ($this->ldapDownloader->isLdapUrl($crlUrl)) {
154                $crlContent = $this->ldapDownloader->download($crlUrl);
155            } else {
156                $crlContent = $this->downloadCrlContent($crlUrl);
157            }
158
159            if (!$crlContent) {
160                throw new \Exception('Failed to get CRL content');
161            }
162
163            return $this->checkCertificateInCrlWithDetails($certPem, $crlContent);
164        } catch (\Exception) {
165            return ['status' => CrlValidationStatus::VALIDATION_ERROR];
166        }
167    }
168
169    private function isLegacyLocalCrlUrl(string $url): bool {
170        $path = parse_url($url, PHP_URL_PATH);
171        if (!is_string($path)) {
172            return false;
173        }
174
175        $templateUrl = $this->urlGenerator->linkToRouteAbsolute('libresign.crl.getRevocationList', [
176            'instanceId' => 'INSTANCEID',
177            'generation' => 999999,
178            'engineType' => 'ENGINETYPE',
179        ]);
180        $templatePath = parse_url($templateUrl, PHP_URL_PATH);
181        if (!is_string($templatePath)) {
182            return false;
183        }
184
185        $routePosition = strrpos($templatePath, '/apps/libresign/crl/');
186        if ($routePosition === false) {
187            return false;
188        }
189
190        $webroot = substr($templatePath, 0, $routePosition);
191        if (str_ends_with($webroot, '/index.php')) {
192            $webroot = substr($webroot, 0, -strlen('/index.php'));
193        }
194
195        $pattern = '#^' . preg_quote($webroot, '#') . '/(?:index\\.php/)?apps/libresign/crl/?$#';
196        return preg_match($pattern, $path) === 1;
197    }
198
199    private function isLocalCrlUrl(string $url): bool {
200        $host = parse_url($url, PHP_URL_HOST);
201        if (!$host) {
202            return false;
203        }
204
205        $trustedDomains = $this->config->getSystemValue('trusted_domains', []);
206
207        return in_array($host, $trustedDomains, true);
208    }
209
210    protected function generateLocalCrl(string $crlUrl): ?string {
211        if (array_key_exists($crlUrl, $this->localCrlRequestCache)) {
212            return $this->localCrlRequestCache[$crlUrl];
213        }
214
215        try {
216            $pattern = $this->getLocalCrlPattern();
217            if (preg_match($pattern, $crlUrl, $matches)) {
218                $instanceId = $matches[1];
219                $generation = (int)$matches[2];
220                $engineType = $matches[3];
221
222                return $this->localCrlRequestCache[$crlUrl] = $this->getCrlService()->generateCrlDer($instanceId, $generation, $engineType);
223            }
224
225            $this->logger->debug('CRL URL does not match expected pattern', ['url' => $crlUrl, 'pattern' => $pattern]);
226            return $this->localCrlRequestCache[$crlUrl] = null;
227        } catch (\Exception $e) {
228            if ($e instanceof \RuntimeException && str_starts_with($e->getMessage(), 'Config path does not exist for instanceId:')) {
229                $this->logger->debug('Skipping local CRL generation because source PKI config path is missing', [
230                    'reason' => $e->getMessage(),
231                ]);
232            } elseif ($e instanceof CrlGenerationInProgressException) {
233                $this->logger->debug('Local CRL generation is already in progress', [
234                    'reason' => $e->getMessage(),
235                ]);
236            } else {
237                $this->logger->warning('Failed to generate local CRL: ' . $e->getMessage());
238            }
239            return $this->localCrlRequestCache[$crlUrl] = null;
240        }
241    }
242
243    /**
244     * Lazy-loaded to avoid a circular dependency:
245     * CrlService → CertificateEngineFactory → OpenSslHandler → CrlRevocationChecker → CrlService
246     */
247    protected function getCrlService(): CrlService {
248        /** @var CrlService */
249        return \OCP\Server::get(CrlService::class);
250    }
251
252    /**
253     * Builds and memoises the regex pattern used to recognise local LibreSign
254     * CRL URLs. The pattern is constructed once per request from the configured
255     * URL generator and then cached in a property to avoid redundant work on
256     * installations that validate many certificates in a single request.
257     */
258    protected function getLocalCrlPattern(): string {
259        if ($this->localCrlPattern !== null) {
260            return $this->localCrlPattern;
261        }
262
263        $templateUrl = $this->urlGenerator->linkToRouteAbsolute('libresign.crl.getRevocationList', [
264            'instanceId' => 'INSTANCEID',
265            'generation' => 999999,
266            'engineType' => 'ENGINETYPE',
267        ]);
268
269        // Match the path only and accept any scheme/host: the CRL DP host is fixed
270        // at certificate issuance and may differ from the request host (already
271        // vetted as trusted by isLocalCrlUrl()).
272        $templatePath = parse_url($templateUrl, PHP_URL_PATH) ?: $templateUrl;
273
274        $patternPath = str_replace('INSTANCEID', '([^/_]+)', $templatePath);
275        $patternPath = str_replace('999999', '(\d+)', $patternPath);
276        $patternPath = str_replace('ENGINETYPE', '([^/_]+)', $patternPath);
277
278        $escapedPattern = str_replace([':', '/', '.'], ['\:', '\/', '\.'], $patternPath);
279        $escapedPattern = str_replace('\/apps\/', '(?:\/index\.php)?\/apps\/', $escapedPattern);
280
281        $this->localCrlPattern = '/^https?\:\/\/[^\/]+' . $escapedPattern . '$/';
282        return $this->localCrlPattern;
283    }
284
285    protected function downloadCrlContent(string $url): ?string {
286        if (!filter_var($url, FILTER_VALIDATE_URL) || !in_array(parse_url($url, PHP_URL_SCHEME), ['http', 'https'])) {
287            return null;
288        }
289
290        $cacheKey = sha1($url);
291        $cached = $this->cache->get($cacheKey);
292        if ($cached !== null) {
293            return is_string($cached) ? $this->decodeCachedBinaryContent($cached) : null;
294        }
295
296        $context = stream_context_create([
297            'http' => [
298                'timeout' => 30,
299                'user_agent' => 'LibreSign/1.0 CRL Validator',
300                'follow_location' => 1,
301                'max_redirects' => 3,
302            ]
303        ]);
304
305        $content = $this->fetchRemoteCrlContent($url, $context);
306        if ($content === false) {
307            return null;
308        }
309
310        $this->cache->set($cacheKey, $this->encodeCacheableBinaryContent($content), 86400);
311        return $content;
312    }
313
314    /**
315     * @param resource $context
316     */
317    protected function fetchRemoteCrlContent(string $url, $context): string|false {
318        return @file_get_contents($url, false, $context);
319    }
320
321    private function encodeCacheableBinaryContent(string $content): string {
322        return self::BINARY_CACHE_PREFIX . base64_encode($content);
323    }
324
325    private function decodeCachedBinaryContent(string $cachedContent): string {
326        if (!str_starts_with($cachedContent, self::BINARY_CACHE_PREFIX)) {
327            return $cachedContent;
328        }
329
330        $decoded = base64_decode(substr($cachedContent, strlen(self::BINARY_CACHE_PREFIX)), true);
331        return $decoded === false ? $cachedContent : $decoded;
332    }
333
334    protected function isSerialNumberInCrl(string $crlText, string $serialNumber): bool {
335        $normalizedSerial = strtoupper($serialNumber);
336        $normalizedSerial = ltrim($normalizedSerial, '0') ?: '0';
337
338        return preg_match('/Serial Number: 0*' . preg_quote($normalizedSerial, '/') . '/', $crlText) === 1;
339    }
340
341    /**
342     * Check if certificate serial is revoked in the provided CRL content.
343     *
344     * @return array{status: CrlValidationStatus, revoked_at?: string}
345     */
346    private function checkCertificateInCrlWithDetails(string $certPem, string $crlContent): array {
347        try {
348            $certResource = openssl_x509_read($certPem);
349            if (!$certResource) {
350                return ['status' => CrlValidationStatus::VALIDATION_ERROR];
351            }
352
353            $certData = openssl_x509_parse($certResource);
354            if (!isset($certData['serialNumber'])) {
355                return ['status' => CrlValidationStatus::VALIDATION_ERROR];
356            }
357
358            $serialCandidates = [$certData['serialNumber']];
359            if (!empty($certData['serialNumberHex'])) {
360                $serialCandidates[] = $certData['serialNumberHex'];
361            }
362
363            $validationCacheKey = $this->buildValidationCacheKey($serialCandidates, $crlContent);
364            if (array_key_exists($validationCacheKey, $this->validationResultRequestCache)) {
365                return $this->validationResultRequestCache[$validationCacheKey];
366            }
367
368            $crlText = $this->getParsedCrlText($crlContent);
369            if ($crlText === null) {
370                return $this->validationResultRequestCache[$validationCacheKey] = ['status' => CrlValidationStatus::VALIDATION_ERROR];
371            }
372
373            foreach ($serialCandidates as $serial) {
374                if ($this->isSerialNumberInCrl($crlText, $serial)) {
375                    $revokedAt = $this->extractRevocationDateFromCrlText($crlText, $serialCandidates);
376                    return $this->validationResultRequestCache[$validationCacheKey] = array_filter([
377                        'status' => CrlValidationStatus::REVOKED,
378                        'revoked_at' => $revokedAt,
379                    ]);
380                }
381            }
382
383            return $this->validationResultRequestCache[$validationCacheKey] = ['status' => CrlValidationStatus::VALID];
384        } catch (\Exception) {
385            return ['status' => CrlValidationStatus::VALIDATION_ERROR];
386        }
387    }
388
389    protected function getParsedCrlText(string $crlContent): ?string {
390        $contentHash = sha1($crlContent);
391
392        if (array_key_exists($contentHash, $this->parsedCrlTextRequestCache)) {
393            return $this->parsedCrlTextRequestCache[$contentHash];
394        }
395
396        $cacheKey = self::PARSED_TEXT_CACHE_PREFIX . $contentHash;
397        $cached = $this->cache->get($cacheKey);
398        if ($cached !== null) {
399            return $this->parsedCrlTextRequestCache[$contentHash] = is_string($cached) ? $cached : null;
400        }
401
402        $tempFile = $this->tempManager->getTemporaryFile('.der');
403        if (!file_put_contents($tempFile, $crlContent)) {
404            return $this->parsedCrlTextRequestCache[$contentHash] = null;
405        }
406
407        [$output, $exitCode] = $this->execOpenSslCrl($tempFile);
408
409        if ($exitCode !== 0 || empty($output)) {
410            return $this->parsedCrlTextRequestCache[$contentHash] = null;
411        }
412
413        $crlText = implode("\n", $output);
414        $this->cache->set($cacheKey, $crlText, self::PARSED_TEXT_CACHE_TTL);
415        return $this->parsedCrlTextRequestCache[$contentHash] = $crlText;
416    }
417
418    protected function buildValidationCacheKey(array $serialCandidates, string $crlContent): string {
419        return sha1(implode(',', $serialCandidates) . ':' . sha1($crlContent));
420    }
421
422    /**
423     * Runs `openssl crl -text -noout` on the given DER file and returns
424     * [output_lines[], exit_code]. Extracted to allow test subclasses to
425     * override it without executing a real process.
426     */
427    protected function execOpenSslCrl(string $tempCrlFile): array {
428        $cmd = sprintf(
429            'openssl crl -in %s -inform DER -text -noout',
430            escapeshellarg($tempCrlFile)
431        );
432        exec($cmd, $output, $exitCode);
433        return [$output, $exitCode];
434    }
435
436    protected function extractRevocationDateFromCrlText(string $crlText, array $serialNumbers): ?string {
437        foreach ($serialNumbers as $serial) {
438            $normalizedSerial = strtoupper(ltrim((string)$serial, '0')) ?: '0';
439            $pattern = '/Serial Number:\s*0*' . preg_quote($normalizedSerial, '/') . '\s*\R\s*Revocation Date:\s*([^\r\n]+)/i';
440            if (preg_match($pattern, $crlText, $matches) !== 1) {
441                continue;
442            }
443            $dateText = trim($matches[1]);
444            try {
445                $date = new \DateTimeImmutable($dateText, new \DateTimeZone('UTC'));
446                return $date->setTimezone(new \DateTimeZone('UTC'))->format(\DateTimeInterface::ATOM);
447            } catch (\Exception) {
448                continue;
449            }
450        }
451        return null;
452    }
453}