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

LibreSign / libresign / 24083625942

07 Apr 2026 01:22PM UTC coverage: 55.6%. First build
24083625942

Pull #7450

github

web-flow
Merge b1c2ec824 into 1112b1165
Pull Request #7450: chore(rector): apply safe test-only batch and php82 baseline

25 of 50 new or added lines in 15 files covered. (50.0%)

10231 of 18401 relevant lines covered (55.6%)

6.61 hits per line

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

65.19
/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');
86✔
45
        }
46

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

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

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

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

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

110
                if ($accessibleUrls === 0) {
4✔
111
                        return ['status' => CrlValidationStatus::URLS_INACCESSIBLE];
4✔
112
                }
113

114
                return ['status' => CrlValidationStatus::VALIDATION_FAILED];
×
115
        }
116

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

132
                        if (!$crlContent) {
4✔
133
                                throw new \Exception('Failed to get CRL content');
4✔
134
                        }
135

136
                        return $this->checkCertificateInCrlWithDetails($certPem, $crlContent);
×
137

138
                } catch (\Exception) {
4✔
139
                        return ['status' => CrlValidationStatus::VALIDATION_ERROR];
4✔
140
                }
141
        }
142

143
        private function isLocalCrlUrl(string $url): bool {
144
                $host = parse_url($url, PHP_URL_HOST);
7✔
145
                if (!$host) {
7✔
146
                        return false;
×
147
                }
148

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

151
                return in_array($host, $trustedDomains, true);
7✔
152
        }
153

154
        private function generateLocalCrl(string $crlUrl): ?string {
155
                try {
156
                        $pattern = $this->getLocalCrlPattern();
3✔
157
                        if (preg_match($pattern, $crlUrl, $matches)) {
3✔
158
                                $instanceId = $matches[1];
2✔
159
                                $generation = (int)$matches[2];
2✔
160
                                $engineType = $matches[3];
2✔
161

162
                                // Lazy-loaded to avoid a circular dependency:
163
                                // CrlService → CertificateEngineFactory → OpenSslHandler → CrlRevocationChecker → CrlService
164
                                /** @var \OCA\Libresign\Service\Crl\CrlService */
165
                                $crlService = \OCP\Server::get(\OCA\Libresign\Service\Crl\CrlService::class);
2✔
166

167
                                return $crlService->generateCrlDer($instanceId, $generation, $engineType);
2✔
168
                        }
169

170
                        $this->logger->debug('CRL URL does not match expected pattern', ['url' => $crlUrl, 'pattern' => $pattern]);
3✔
171
                        return null;
3✔
172
                } catch (\Exception $e) {
2✔
173
                        $this->logger->warning('Failed to generate local CRL: ' . $e->getMessage());
2✔
174
                        return null;
2✔
175
                }
176
        }
177

178
        /**
179
         * Builds and memoises the regex pattern used to recognise local LibreSign
180
         * CRL URLs. The pattern is constructed once per request from the configured
181
         * URL generator and then cached in a property to avoid redundant work on
182
         * installations that validate many certificates in a single request.
183
         */
184
        private function getLocalCrlPattern(): string {
185
                if ($this->localCrlPattern !== null) {
3✔
186
                        return $this->localCrlPattern;
2✔
187
                }
188

189
                $templateUrl = $this->urlGenerator->linkToRouteAbsolute('libresign.crl.getRevocationList', [
2✔
190
                        'instanceId' => 'INSTANCEID',
2✔
191
                        'generation' => 999999,
2✔
192
                        'engineType' => 'ENGINETYPE',
2✔
193
                ]);
2✔
194

195
                $patternUrl = str_replace('INSTANCEID', '([^/_]+)', $templateUrl);
2✔
196
                $patternUrl = str_replace('999999', '(\d+)', $patternUrl);
2✔
197
                $patternUrl = str_replace('ENGINETYPE', '([^/_]+)', $patternUrl);
2✔
198

199
                $escapedPattern = str_replace([':', '/', '.'], ['\:', '\/', '\.'], $patternUrl);
2✔
200
                $escapedPattern = str_replace('\/apps\/', '(?:\/index\.php)?\/apps\/', $escapedPattern);
2✔
201

202
                $this->localCrlPattern = '/^' . $escapedPattern . '$/';
2✔
203
                return $this->localCrlPattern;
2✔
204
        }
205

206
        private function downloadCrlContent(string $url): ?string {
207
                if (!filter_var($url, FILTER_VALIDATE_URL) || !in_array(parse_url($url, PHP_URL_SCHEME), ['http', 'https'])) {
1✔
208
                        return null;
×
209
                }
210

211
                $cacheKey = sha1($url);
1✔
212
                $cached = $this->cache->get($cacheKey);
1✔
213
                if ($cached !== null) {
1✔
214
                        return $cached;
×
215
                }
216

217
                $context = stream_context_create([
1✔
218
                        'http' => [
1✔
219
                                'timeout' => 30,
1✔
220
                                'user_agent' => 'LibreSign/1.0 CRL Validator',
1✔
221
                                'follow_location' => 1,
1✔
222
                                'max_redirects' => 3,
1✔
223
                        ]
1✔
224
                ]);
1✔
225

226
                $content = @file_get_contents($url, false, $context);
1✔
227
                if ($content === false) {
1✔
228
                        return null;
1✔
229
                }
230

231
                $this->cache->set($cacheKey, $content, 86400);
×
232
                return $content;
×
233
        }
234

235
        protected function isSerialNumberInCrl(string $crlText, string $serialNumber): bool {
236
                $normalizedSerial = strtoupper($serialNumber);
7✔
237
                $normalizedSerial = ltrim($normalizedSerial, '0') ?: '0';
7✔
238

239
                return preg_match('/Serial Number: 0*' . preg_quote($normalizedSerial, '/') . '/', $crlText) === 1;
7✔
240
        }
241

242
        /**
243
         * Check if certificate serial is revoked in the provided CRL content.
244
         *
245
         * @return array{status: CrlValidationStatus, revoked_at?: string}
246
         */
247
        private function checkCertificateInCrlWithDetails(string $certPem, string $crlContent): array {
248
                try {
249
                        $certResource = openssl_x509_read($certPem);
×
250
                        if (!$certResource) {
×
251
                                return ['status' => CrlValidationStatus::VALIDATION_ERROR];
×
252
                        }
253

254
                        $certData = openssl_x509_parse($certResource);
×
255
                        if (!isset($certData['serialNumber'])) {
×
256
                                return ['status' => CrlValidationStatus::VALIDATION_ERROR];
×
257
                        }
258

259
                        $tempCrlFile = $this->tempManager->getTemporaryFile('.crl');
×
260
                        file_put_contents($tempCrlFile, $crlContent);
×
261

262
                        try {
263
                                [$output, $exitCode] = $this->execOpenSslCrl($tempCrlFile);
×
264

265
                                if ($exitCode !== 0) {
×
266
                                        return ['status' => CrlValidationStatus::VALIDATION_ERROR];
×
267
                                }
268

269
                                $crlText = implode("\n", $output);
×
270
                                $serialCandidates = [$certData['serialNumber']];
×
271
                                if (!empty($certData['serialNumberHex'])) {
×
272
                                        $serialCandidates[] = $certData['serialNumberHex'];
×
273
                                }
274

275
                                foreach ($serialCandidates as $serial) {
×
276
                                        if ($this->isSerialNumberInCrl($crlText, $serial)) {
×
277
                                                $revokedAt = $this->extractRevocationDateFromCrlText($crlText, $serialCandidates);
×
278
                                                return array_filter([
×
279
                                                        'status' => CrlValidationStatus::REVOKED,
×
280
                                                        'revoked_at' => $revokedAt,
×
281
                                                ]);
×
282
                                        }
283
                                }
284

285
                                return ['status' => CrlValidationStatus::VALID];
×
286

287
                        } finally {
288
                                if (file_exists($tempCrlFile)) {
×
289
                                        unlink($tempCrlFile);
×
290
                                }
291
                        }
292

NEW
293
                } catch (\Exception) {
×
294
                        return ['status' => CrlValidationStatus::VALIDATION_ERROR];
×
295
                }
296
        }
297

298
        /**
299
         * Runs `openssl crl -text -noout` on the given DER file and returns
300
         * [output_lines[], exit_code]. Extracted to allow test subclasses to
301
         * override it without executing a real process.
302
         */
303
        protected function execOpenSslCrl(string $tempCrlFile): array {
304
                $cmd = sprintf(
×
305
                        'openssl crl -in %s -inform DER -text -noout',
×
306
                        escapeshellarg($tempCrlFile)
×
307
                );
×
308
                exec($cmd, $output, $exitCode);
×
309
                return [$output, $exitCode];
×
310
        }
311

312
        protected function extractRevocationDateFromCrlText(string $crlText, array $serialNumbers): ?string {
313
                foreach ($serialNumbers as $serial) {
3✔
314
                        $normalizedSerial = strtoupper(ltrim((string)$serial, '0')) ?: '0';
3✔
315
                        $pattern = '/Serial Number:\s*0*' . preg_quote($normalizedSerial, '/') . '\s*\R\s*Revocation Date:\s*([^\r\n]+)/i';
3✔
316
                        if (preg_match($pattern, $crlText, $matches) !== 1) {
3✔
317
                                continue;
1✔
318
                        }
319
                        $dateText = trim($matches[1]);
2✔
320
                        try {
321
                                $date = new \DateTimeImmutable($dateText, new \DateTimeZone('UTC'));
2✔
322
                                return $date->setTimezone(new \DateTimeZone('UTC'))->format(\DateTimeInterface::ATOM);
2✔
NEW
323
                        } catch (\Exception) {
×
324
                                continue;
×
325
                        }
326
                }
327
                return null;
1✔
328
        }
329
}
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