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

LibreSign / libresign / 22700750930

05 Mar 2026 03:23AM UTC coverage: 54.096%. First build
22700750930

Pull #7081

github

web-flow
Merge 068926532 into fe25455de
Pull Request #7081: feat: crl revocation checker

172 of 240 new or added lines in 9 files covered. (71.67%)

9839 of 18188 relevant lines covered (54.1%)

6.37 hits per line

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

64.66
/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
        /** Distributed cache for externally downloaded CRL content (TTL: 24 h). */
33
        private ICache $cache;
34

35
        public function __construct(
36
                private IConfig $config,
37
                private IAppConfig $appConfig,
38
                private IURLGenerator $urlGenerator,
39
                private ITempManager $tempManager,
40
                private LoggerInterface $logger,
41
                ICacheFactory $cacheFactory,
42
                private LdapCrlDownloader $ldapDownloader,
43
        ) {
44
                $this->cache = $cacheFactory->createDistributed('libresign_crl');
85✔
45
        }
46

47
        /**
48
         * Validate a certificate against the CRL Distribution Points found in its
49
         * data. Returns an array with at least a 'status' key ({@see CrlValidationStatus})
50
         * and optionally 'revoked_at' (ISO 8601) when the certificate is revoked.
51
         */
52
        public function validate(array $crlUrls, string $certPem): array {
53
                return $this->validateFromUrlsWithDetails($crlUrls, $certPem);
8✔
54
        }
55

56
        private function validateFromUrlsWithDetails(array $crlUrls, string $certPem): array {
57
                if (empty($crlUrls)) {
8✔
58
                        return ['status' => CrlValidationStatus::NO_URLS];
1✔
59
                }
60

61
                $externalValidationEnabled = $this->appConfig->getValueBool(Application::APP_ID, 'crl_external_validation_enabled', true);
7✔
62

63
                $accessibleUrls = 0;
7✔
64
                $disabledUrls = 0;
7✔
65
                foreach ($crlUrls as $crlUrl) {
7✔
66
                        try {
67
                                $isLocal = $this->isLocalCrlUrl($crlUrl);
7✔
68
                                // Skip external CRL validation when disabled by admin, but always
69
                                // validate local LibreSign-managed CRLs.
70
                                if (!$externalValidationEnabled && !$isLocal) {
7✔
71
                                        $disabledUrls++;
3✔
72
                                        continue;
3✔
73
                                }
74
                                $validationResult = $this->downloadAndValidateWithDetails($crlUrl, $certPem, $isLocal);
4✔
75
                                if ($validationResult['status'] === CrlValidationStatus::VALID) {
4✔
NEW
76
                                        return $validationResult;
×
77
                                }
78
                                if ($validationResult['status'] === CrlValidationStatus::REVOKED) {
4✔
NEW
79
                                        return $validationResult;
×
80
                                }
81
                                // Only count as accessible if we actually reached the server and parsed
82
                                // a CRL response – validation_error means the download itself failed.
83
                                if ($validationResult['status'] !== CrlValidationStatus::VALIDATION_ERROR) {
4✔
84
                                        $accessibleUrls++;
4✔
85
                                }
NEW
86
                        } catch (\Exception $e) {
×
NEW
87
                                continue;
×
88
                        }
89
                }
90

91
                // All distribution points were intentionally skipped because the admin
92
                // disabled external CRL validation.
93
                if ($disabledUrls > 0 && $accessibleUrls === 0) {
7✔
94
                        return ['status' => CrlValidationStatus::DISABLED];
3✔
95
                }
96

97
                if ($accessibleUrls === 0) {
4✔
98
                        return ['status' => CrlValidationStatus::URLS_INACCESSIBLE];
4✔
99
                }
100

NEW
101
                return ['status' => CrlValidationStatus::VALIDATION_FAILED];
×
102
        }
103

104
        private function downloadAndValidateWithDetails(string $crlUrl, string $certPem, bool $isLocal): array {
105
                try {
106
                        if ($isLocal) {
4✔
107
                                $crlContent = $this->generateLocalCrl($crlUrl);
3✔
108
                        } elseif ($this->ldapDownloader->isLdapUrl($crlUrl)) {
1✔
NEW
109
                                $crlContent = $this->ldapDownloader->download($crlUrl);
×
110
                        } else {
111
                                $crlContent = $this->downloadCrlContent($crlUrl);
1✔
112
                        }
113

114
                        if (!$crlContent) {
4✔
115
                                throw new \Exception('Failed to get CRL content');
4✔
116
                        }
117

NEW
118
                        return $this->checkCertificateInCrlWithDetails($certPem, $crlContent);
×
119

120
                } catch (\Exception $e) {
4✔
121
                        return ['status' => CrlValidationStatus::VALIDATION_ERROR];
4✔
122
                }
123
        }
124

125
        private function isLocalCrlUrl(string $url): bool {
126
                $host = parse_url($url, PHP_URL_HOST);
7✔
127
                if (!$host) {
7✔
NEW
128
                        return false;
×
129
                }
130

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

133
                return in_array($host, $trustedDomains, true);
7✔
134
        }
135

136
        private function generateLocalCrl(string $crlUrl): ?string {
137
                try {
138
                        $pattern = $this->getLocalCrlPattern();
3✔
139
                        if (preg_match($pattern, $crlUrl, $matches)) {
3✔
140
                                $instanceId = $matches[1];
2✔
141
                                $generation = (int)$matches[2];
2✔
142
                                $engineType = $matches[3];
2✔
143

144
                                // Lazy-loaded to avoid a circular dependency:
145
                                // CrlService → CertificateEngineFactory → OpenSslHandler → CrlRevocationChecker → CrlService
146
                                /** @var \OCA\Libresign\Service\Crl\CrlService */
147
                                $crlService = \OC::$server->get(\OCA\Libresign\Service\Crl\CrlService::class);
2✔
148

149
                                return $crlService->generateCrlDer($instanceId, $generation, $engineType);
2✔
150
                        }
151

152
                        $this->logger->debug('CRL URL does not match expected pattern', ['url' => $crlUrl, 'pattern' => $pattern]);
3✔
153
                        return null;
3✔
154
                } catch (\Exception $e) {
2✔
155
                        $this->logger->warning('Failed to generate local CRL: ' . $e->getMessage());
2✔
156
                        return null;
2✔
157
                }
158
        }
159

160
        /**
161
         * Builds and memoises the regex pattern used to recognise local LibreSign
162
         * CRL URLs. The pattern is constructed once per request from the configured
163
         * URL generator and then cached in a property to avoid redundant work on
164
         * installations that validate many certificates in a single request.
165
         */
166
        private function getLocalCrlPattern(): string {
167
                if ($this->localCrlPattern !== null) {
3✔
168
                        return $this->localCrlPattern;
2✔
169
                }
170

171
                $templateUrl = $this->urlGenerator->linkToRouteAbsolute('libresign.crl.getRevocationList', [
2✔
172
                        'instanceId' => 'INSTANCEID',
2✔
173
                        'generation' => 999999,
2✔
174
                        'engineType' => 'ENGINETYPE',
2✔
175
                ]);
2✔
176

177
                $patternUrl = str_replace('INSTANCEID', '([^/_]+)', $templateUrl);
2✔
178
                $patternUrl = str_replace('999999', '(\d+)', $patternUrl);
2✔
179
                $patternUrl = str_replace('ENGINETYPE', '([^/_]+)', $patternUrl);
2✔
180

181
                $escapedPattern = str_replace([':', '/', '.'], ['\:', '\/', '\.'], $patternUrl);
2✔
182
                $escapedPattern = str_replace('\/apps\/', '(?:\/index\.php)?\/apps\/', $escapedPattern);
2✔
183

184
                $this->localCrlPattern = '/^' . $escapedPattern . '$/';
2✔
185
                return $this->localCrlPattern;
2✔
186
        }
187

188
        private function downloadCrlContent(string $url): ?string {
189
                if (!filter_var($url, FILTER_VALIDATE_URL) || !in_array(parse_url($url, PHP_URL_SCHEME), ['http', 'https'])) {
1✔
NEW
190
                        return null;
×
191
                }
192

193
                $cacheKey = sha1($url);
1✔
194
                $cached = $this->cache->get($cacheKey);
1✔
195
                if ($cached !== null) {
1✔
NEW
196
                        return $cached;
×
197
                }
198

199
                $context = stream_context_create([
1✔
200
                        'http' => [
1✔
201
                                'timeout' => 30,
1✔
202
                                'user_agent' => 'LibreSign/1.0 CRL Validator',
1✔
203
                                'follow_location' => 1,
1✔
204
                                'max_redirects' => 3,
1✔
205
                        ]
1✔
206
                ]);
1✔
207

208
                $content = @file_get_contents($url, false, $context);
1✔
209
                if ($content === false) {
1✔
210
                        return null;
1✔
211
                }
212

NEW
213
                $this->cache->set($cacheKey, $content, 86400);
×
NEW
214
                return $content;
×
215
        }
216

217
        protected function isSerialNumberInCrl(string $crlText, string $serialNumber): bool {
218
                $normalizedSerial = strtoupper($serialNumber);
7✔
219
                $normalizedSerial = ltrim($normalizedSerial, '0') ?: '0';
7✔
220

221
                return preg_match('/Serial Number: 0*' . preg_quote($normalizedSerial, '/') . '/', $crlText) === 1;
7✔
222
        }
223

224
        private function checkCertificateInCrlWithDetails(string $certPem, string $crlContent): array {
225
                try {
NEW
226
                        $certResource = openssl_x509_read($certPem);
×
NEW
227
                        if (!$certResource) {
×
NEW
228
                                return ['status' => CrlValidationStatus::VALIDATION_ERROR];
×
229
                        }
230

NEW
231
                        $certData = openssl_x509_parse($certResource);
×
NEW
232
                        if (!isset($certData['serialNumber'])) {
×
NEW
233
                                return ['status' => CrlValidationStatus::VALIDATION_ERROR];
×
234
                        }
235

NEW
236
                        $tempCrlFile = $this->tempManager->getTemporaryFile('.crl');
×
NEW
237
                        file_put_contents($tempCrlFile, $crlContent);
×
238

239
                        try {
NEW
240
                                [$output, $exitCode] = $this->execOpenSslCrl($tempCrlFile);
×
241

NEW
242
                                if ($exitCode !== 0) {
×
NEW
243
                                        return ['status' => CrlValidationStatus::VALIDATION_ERROR];
×
244
                                }
245

NEW
246
                                $crlText = implode("\n", $output);
×
NEW
247
                                $serialCandidates = [$certData['serialNumber']];
×
NEW
248
                                if (!empty($certData['serialNumberHex'])) {
×
NEW
249
                                        $serialCandidates[] = $certData['serialNumberHex'];
×
250
                                }
251

NEW
252
                                foreach ($serialCandidates as $serial) {
×
NEW
253
                                        if ($this->isSerialNumberInCrl($crlText, $serial)) {
×
NEW
254
                                                $revokedAt = $this->extractRevocationDateFromCrlText($crlText, $serialCandidates);
×
NEW
255
                                                return array_filter([
×
NEW
256
                                                        'status' => CrlValidationStatus::REVOKED,
×
NEW
257
                                                        'revoked_at' => $revokedAt,
×
NEW
258
                                                ]);
×
259
                                        }
260
                                }
261

NEW
262
                                return ['status' => CrlValidationStatus::VALID];
×
263

264
                        } finally {
NEW
265
                                if (file_exists($tempCrlFile)) {
×
NEW
266
                                        unlink($tempCrlFile);
×
267
                                }
268
                        }
269

NEW
270
                } catch (\Exception $e) {
×
NEW
271
                        return ['status' => CrlValidationStatus::VALIDATION_ERROR];
×
272
                }
273
        }
274

275
        /**
276
         * Runs `openssl crl -text -noout` on the given DER file and returns
277
         * [output_lines[], exit_code]. Extracted to allow test subclasses to
278
         * override it without executing a real process.
279
         */
280
        protected function execOpenSslCrl(string $tempCrlFile): array {
NEW
281
                $cmd = sprintf(
×
NEW
282
                        'openssl crl -in %s -inform DER -text -noout',
×
NEW
283
                        escapeshellarg($tempCrlFile)
×
NEW
284
                );
×
NEW
285
                exec($cmd, $output, $exitCode);
×
NEW
286
                return [$output, $exitCode];
×
287
        }
288

289
        protected function extractRevocationDateFromCrlText(string $crlText, array $serialNumbers): ?string {
290
                foreach ($serialNumbers as $serial) {
3✔
291
                        $normalizedSerial = strtoupper(ltrim((string)$serial, '0')) ?: '0';
3✔
292
                        $pattern = '/Serial Number:\s*0*' . preg_quote($normalizedSerial, '/') . '\s*\R\s*Revocation Date:\s*([^\r\n]+)/i';
3✔
293
                        if (preg_match($pattern, $crlText, $matches) !== 1) {
3✔
294
                                continue;
1✔
295
                        }
296
                        $dateText = trim($matches[1]);
2✔
297
                        try {
298
                                $date = new \DateTimeImmutable($dateText, new \DateTimeZone('UTC'));
2✔
299
                                return $date->setTimezone(new \DateTimeZone('UTC'))->format(\DateTimeInterface::ATOM);
2✔
NEW
300
                        } catch (\Exception $e) {
×
NEW
301
                                continue;
×
302
                        }
303
                }
304
                return null;
1✔
305
        }
306
}
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