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

LibreSign / libresign / 27520589845

15 Jun 2026 02:33AM UTC coverage: 57.361%. First build
27520589845

Pull #7778

github

web-flow
Merge a4dcc27ad into c8b5a8266
Pull Request #7778: fix: crl appdata cache

211 of 268 new or added lines in 5 files covered. (78.73%)

11038 of 19243 relevant lines covered (57.36%)

6.99 hits per line

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

68.92
/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\AppInfo\Application;
13
use OCA\Libresign\Enum\CrlValidationStatus;
14
use OCA\Libresign\Service\Crl\Ldap\LdapCrlDownloader;
15
use OCP\IAppConfig;
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
        /** Distributed cache for externally downloaded CRL content (TTL: 24 h). */
36
        private ICache $cache;
37

38
        private const BINARY_CACHE_PREFIX = 'base64:';
39

40
        public function __construct(
41
                private IConfig $config,
42
                private IAppConfig $appConfig,
43
                private IURLGenerator $urlGenerator,
44
                private ITempManager $tempManager,
45
                private LoggerInterface $logger,
46
                ICacheFactory $cacheFactory,
47
                private LdapCrlDownloader $ldapDownloader,
48
        ) {
49
                $this->cache = $cacheFactory->createDistributed('libresign_crl');
97✔
50
        }
51

52
        /**
53
         * Validate a certificate against the CRL Distribution Points found in its
54
         * data. Returns an array with a 'status' key (always {@see CrlValidationStatus})
55
         * and optionally 'revoked_at' (ISO 8601) when the certificate is revoked.
56
         *
57
         * @return array{status: CrlValidationStatus, revoked_at?: string}
58
         */
59
        public function validate(array $crlUrls, string $certPem): array {
60
                return $this->validateFromUrlsWithDetails($crlUrls, $certPem);
9✔
61
        }
62

63
        /**
64
         * Internal validation worker that iterates through CRL distribution points
65
         * and returns the validation status from the first accessible/conclusive point.
66
         *
67
         * @return array{status: CrlValidationStatus, revoked_at?: string}
68
         */
69
        private function validateFromUrlsWithDetails(array $crlUrls, string $certPem): array {
70
                $externalValidationEnabled = $this->appConfig->getValueBool(Application::APP_ID, 'crl_external_validation_enabled', true);
9✔
71

72
                if (empty($crlUrls)) {
9✔
73
                        // When external validation is disabled, treat an empty distribution-point
74
                        // list the same as if all points were intentionally skipped.
75
                        if (!$externalValidationEnabled) {
2✔
76
                                return ['status' => CrlValidationStatus::DISABLED];
1✔
77
                        }
78
                        return ['status' => CrlValidationStatus::NO_URLS];
1✔
79
                }
80

81
                $accessibleUrls = 0;
7✔
82
                $disabledUrls = 0;
7✔
83
                foreach ($crlUrls as $crlUrl) {
7✔
84
                        try {
85
                                $isLocal = $this->isLocalCrlUrl($crlUrl);
7✔
86
                                // Skip external CRL validation when disabled by admin, but always
87
                                // validate local LibreSign-managed CRLs.
88
                                if (!$externalValidationEnabled && !$isLocal) {
7✔
89
                                        $disabledUrls++;
3✔
90
                                        continue;
3✔
91
                                }
92
                                $validationResult = $this->downloadAndValidateWithDetails($crlUrl, $certPem, $isLocal);
4✔
93
                                if ($validationResult['status'] === CrlValidationStatus::VALID) {
4✔
94
                                        return $validationResult;
×
95
                                }
96
                                if ($validationResult['status'] === CrlValidationStatus::REVOKED) {
4✔
97
                                        return $validationResult;
×
98
                                }
99
                                // Only count as accessible if we actually reached the server and parsed
100
                                // a CRL response – validation_error means the download itself failed.
101
                                if ($validationResult['status'] !== CrlValidationStatus::VALIDATION_ERROR) {
4✔
102
                                        $accessibleUrls++;
4✔
103
                                }
104
                        } catch (\Exception) {
×
105
                                continue;
×
106
                        }
107
                }
108

109
                // All distribution points were intentionally skipped because the admin
110
                // disabled external CRL validation.
111
                if ($disabledUrls > 0 && $accessibleUrls === 0) {
7✔
112
                        return ['status' => CrlValidationStatus::DISABLED];
3✔
113
                }
114

115
                if ($accessibleUrls === 0) {
4✔
116
                        return ['status' => CrlValidationStatus::URLS_INACCESSIBLE];
4✔
117
                }
118

119
                return ['status' => CrlValidationStatus::VALIDATION_FAILED];
×
120
        }
121

122
        /**
123
         * Download and validate CRL content from a single source URL.
124
         *
125
         * @return array{status: CrlValidationStatus, revoked_at?: string}
126
         */
127
        private function downloadAndValidateWithDetails(string $crlUrl, string $certPem, bool $isLocal): array {
128
                try {
129
                        if ($isLocal) {
4✔
130
                                $crlContent = $this->generateLocalCrl($crlUrl);
3✔
131
                        } elseif ($this->ldapDownloader->isLdapUrl($crlUrl)) {
1✔
132
                                $crlContent = $this->ldapDownloader->download($crlUrl);
×
133
                        } else {
134
                                $crlContent = $this->downloadCrlContent($crlUrl);
1✔
135
                        }
136

137
                        if (!$crlContent) {
4✔
138
                                throw new \Exception('Failed to get CRL content');
4✔
139
                        }
140

141
                        return $this->checkCertificateInCrlWithDetails($certPem, $crlContent);
×
142
                } catch (\Exception) {
4✔
143
                        return ['status' => CrlValidationStatus::VALIDATION_ERROR];
4✔
144
                }
145
        }
146

147
        private function isLocalCrlUrl(string $url): bool {
148
                $host = parse_url($url, PHP_URL_HOST);
7✔
149
                if (!$host) {
7✔
150
                        return false;
×
151
                }
152

153
                $trustedDomains = $this->config->getSystemValue('trusted_domains', []);
7✔
154

155
                return in_array($host, $trustedDomains, true);
7✔
156
        }
157

158
        protected function generateLocalCrl(string $crlUrl): ?string {
159
                if (array_key_exists($crlUrl, $this->localCrlRequestCache)) {
4✔
160
                        return $this->localCrlRequestCache[$crlUrl];
2✔
161
                }
162

163
                try {
164
                        $pattern = $this->getLocalCrlPattern();
3✔
165
                        if (preg_match($pattern, $crlUrl, $matches)) {
3✔
166
                                $instanceId = $matches[1];
2✔
167
                                $generation = (int)$matches[2];
2✔
168
                                $engineType = $matches[3];
2✔
169

170
                                return $this->localCrlRequestCache[$crlUrl] = $this->getCrlService()->generateCrlDer($instanceId, $generation, $engineType);
2✔
171
                        }
172

173
                        $this->logger->debug('CRL URL does not match expected pattern', ['url' => $crlUrl, 'pattern' => $pattern]);
2✔
174
                        return $this->localCrlRequestCache[$crlUrl] = null;
2✔
175
                } catch (\Exception $e) {
1✔
176
                        if ($e instanceof \RuntimeException && str_starts_with($e->getMessage(), 'Config path does not exist for instanceId:')) {
1✔
177
                                $this->logger->debug('Skipping local CRL generation because source PKI config path is missing', [
1✔
178
                                        'reason' => $e->getMessage(),
1✔
179
                                ]);
1✔
180
                        } else {
181
                                $this->logger->warning('Failed to generate local CRL: ' . $e->getMessage());
×
182
                        }
183
                        return $this->localCrlRequestCache[$crlUrl] = null;
1✔
184
                }
185
        }
186

187
        /**
188
         * Lazy-loaded to avoid a circular dependency:
189
         * CrlService → CertificateEngineFactory → OpenSslHandler → CrlRevocationChecker → CrlService
190
         */
191
        protected function getCrlService(): CrlService {
192
                /** @var CrlService */
193
                return \OCP\Server::get(CrlService::class);
1✔
194
        }
195

196
        /**
197
         * Builds and memoises the regex pattern used to recognise local LibreSign
198
         * CRL URLs. The pattern is constructed once per request from the configured
199
         * URL generator and then cached in a property to avoid redundant work on
200
         * installations that validate many certificates in a single request.
201
         */
202
        protected function getLocalCrlPattern(): string {
203
                if ($this->localCrlPattern !== null) {
7✔
204
                        return $this->localCrlPattern;
1✔
205
                }
206

207
                $templateUrl = $this->urlGenerator->linkToRouteAbsolute('libresign.crl.getRevocationList', [
7✔
208
                        'instanceId' => 'INSTANCEID',
7✔
209
                        'generation' => 999999,
7✔
210
                        'engineType' => 'ENGINETYPE',
7✔
211
                ]);
7✔
212

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

218
                $patternPath = str_replace('INSTANCEID', '([^/_]+)', $templatePath);
7✔
219
                $patternPath = str_replace('999999', '(\d+)', $patternPath);
7✔
220
                $patternPath = str_replace('ENGINETYPE', '([^/_]+)', $patternPath);
7✔
221

222
                $escapedPattern = str_replace([':', '/', '.'], ['\:', '\/', '\.'], $patternPath);
7✔
223
                $escapedPattern = str_replace('\/apps\/', '(?:\/index\.php)?\/apps\/', $escapedPattern);
7✔
224

225
                $this->localCrlPattern = '/^https?\:\/\/[^\/]+' . $escapedPattern . '$/';
7✔
226
                return $this->localCrlPattern;
7✔
227
        }
228

229
        protected function downloadCrlContent(string $url): ?string {
230
                if (!filter_var($url, FILTER_VALIDATE_URL) || !in_array(parse_url($url, PHP_URL_SCHEME), ['http', 'https'])) {
3✔
231
                        return null;
×
232
                }
233

234
                $cacheKey = sha1($url);
3✔
235
                $cached = $this->cache->get($cacheKey);
3✔
236
                if ($cached !== null) {
3✔
237
                        return is_string($cached) ? $this->decodeCachedBinaryContent($cached) : null;
1✔
238
                }
239

240
                $context = stream_context_create([
2✔
241
                        'http' => [
2✔
242
                                'timeout' => 30,
2✔
243
                                'user_agent' => 'LibreSign/1.0 CRL Validator',
2✔
244
                                'follow_location' => 1,
2✔
245
                                'max_redirects' => 3,
2✔
246
                        ]
2✔
247
                ]);
2✔
248

249
                $content = $this->fetchRemoteCrlContent($url, $context);
2✔
250
                if ($content === false) {
2✔
251
                        return null;
1✔
252
                }
253

254
                $this->cache->set($cacheKey, $this->encodeCacheableBinaryContent($content), 86400);
1✔
255
                return $content;
1✔
256
        }
257

258
        /**
259
         * @param resource $context
260
         */
261
        protected function fetchRemoteCrlContent(string $url, $context): string|false {
262
                return @file_get_contents($url, false, $context);
1✔
263
        }
264

265
        private function encodeCacheableBinaryContent(string $content): string {
266
                return self::BINARY_CACHE_PREFIX . base64_encode($content);
1✔
267
        }
268

269
        private function decodeCachedBinaryContent(string $cachedContent): string {
270
                if (!str_starts_with($cachedContent, self::BINARY_CACHE_PREFIX)) {
1✔
NEW
271
                        return $cachedContent;
×
272
                }
273

274
                $decoded = base64_decode(substr($cachedContent, strlen(self::BINARY_CACHE_PREFIX)), true);
1✔
275
                return $decoded === false ? $cachedContent : $decoded;
1✔
276
        }
277

278
        protected function isSerialNumberInCrl(string $crlText, string $serialNumber): bool {
279
                $normalizedSerial = strtoupper($serialNumber);
7✔
280
                $normalizedSerial = ltrim($normalizedSerial, '0') ?: '0';
7✔
281

282
                return preg_match('/Serial Number: 0*' . preg_quote($normalizedSerial, '/') . '/', $crlText) === 1;
7✔
283
        }
284

285
        /**
286
         * Check if certificate serial is revoked in the provided CRL content.
287
         *
288
         * @return array{status: CrlValidationStatus, revoked_at?: string}
289
         */
290
        private function checkCertificateInCrlWithDetails(string $certPem, string $crlContent): array {
291
                try {
292
                        $certResource = openssl_x509_read($certPem);
×
293
                        if (!$certResource) {
×
294
                                return ['status' => CrlValidationStatus::VALIDATION_ERROR];
×
295
                        }
296

297
                        $certData = openssl_x509_parse($certResource);
×
298
                        if (!isset($certData['serialNumber'])) {
×
299
                                return ['status' => CrlValidationStatus::VALIDATION_ERROR];
×
300
                        }
301

302
                        $tempCrlFile = $this->tempManager->getTemporaryFile('.crl');
×
303
                        file_put_contents($tempCrlFile, $crlContent);
×
304

305
                        try {
306
                                [$output, $exitCode] = $this->execOpenSslCrl($tempCrlFile);
×
307

308
                                if ($exitCode !== 0) {
×
309
                                        return ['status' => CrlValidationStatus::VALIDATION_ERROR];
×
310
                                }
311

312
                                $crlText = implode("\n", $output);
×
313
                                $serialCandidates = [$certData['serialNumber']];
×
314
                                if (!empty($certData['serialNumberHex'])) {
×
315
                                        $serialCandidates[] = $certData['serialNumberHex'];
×
316
                                }
317

318
                                foreach ($serialCandidates as $serial) {
×
319
                                        if ($this->isSerialNumberInCrl($crlText, $serial)) {
×
320
                                                $revokedAt = $this->extractRevocationDateFromCrlText($crlText, $serialCandidates);
×
321
                                                return array_filter([
×
322
                                                        'status' => CrlValidationStatus::REVOKED,
×
323
                                                        'revoked_at' => $revokedAt,
×
324
                                                ]);
×
325
                                        }
326
                                }
327

328
                                return ['status' => CrlValidationStatus::VALID];
×
329
                        } finally {
330
                                if (file_exists($tempCrlFile)) {
×
331
                                        unlink($tempCrlFile);
×
332
                                }
333
                        }
334

335
                } catch (\Exception) {
×
336
                        return ['status' => CrlValidationStatus::VALIDATION_ERROR];
×
337
                }
338
        }
339

340
        /**
341
         * Runs `openssl crl -text -noout` on the given DER file and returns
342
         * [output_lines[], exit_code]. Extracted to allow test subclasses to
343
         * override it without executing a real process.
344
         */
345
        protected function execOpenSslCrl(string $tempCrlFile): array {
346
                $cmd = sprintf(
×
347
                        'openssl crl -in %s -inform DER -text -noout',
×
348
                        escapeshellarg($tempCrlFile)
×
349
                );
×
350
                exec($cmd, $output, $exitCode);
×
351
                return [$output, $exitCode];
×
352
        }
353

354
        protected function extractRevocationDateFromCrlText(string $crlText, array $serialNumbers): ?string {
355
                foreach ($serialNumbers as $serial) {
3✔
356
                        $normalizedSerial = strtoupper(ltrim((string)$serial, '0')) ?: '0';
3✔
357
                        $pattern = '/Serial Number:\s*0*' . preg_quote($normalizedSerial, '/') . '\s*\R\s*Revocation Date:\s*([^\r\n]+)/i';
3✔
358
                        if (preg_match($pattern, $crlText, $matches) !== 1) {
3✔
359
                                continue;
1✔
360
                        }
361
                        $dateText = trim($matches[1]);
2✔
362
                        try {
363
                                $date = new \DateTimeImmutable($dateText, new \DateTimeZone('UTC'));
2✔
364
                                return $date->setTimezone(new \DateTimeZone('UTC'))->format(\DateTimeInterface::ATOM);
2✔
365
                        } catch (\Exception) {
×
366
                                continue;
×
367
                        }
368
                }
369
                return null;
1✔
370
        }
371
}
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