Skip to content

Commit cfbb849

Browse files
committed
feat(crl): add CrlRevocationChecker service with HTTP/LDAP support
Checks whether a certificate serial number appears in any CRL listed in its CDP extension. Supports HTTP(S) and LDAP(S) download URLs; HTTP responses are cached 24 h. The local-CA pattern is memoized to avoid rebuilding the regex on every call. openssl crl invocation is extracted to a protected method for subclass-level test overriding. Signed-off-by: Vitor Mattos <1079143+vitormattos@users.noreply.github.com>
1 parent 77507a0 commit cfbb849

1 file changed

Lines changed: 306 additions & 0 deletions

File tree

Lines changed: 306 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,306 @@
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');
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);
54+
}
55+
56+
private function validateFromUrlsWithDetails(array $crlUrls, string $certPem): array {
57+
if (empty($crlUrls)) {
58+
return ['status' => CrlValidationStatus::NO_URLS];
59+
}
60+
61+
$externalValidationEnabled = $this->appConfig->getValueBool(Application::APP_ID, 'crl_external_validation_enabled', true);
62+
63+
$accessibleUrls = 0;
64+
$disabledUrls = 0;
65+
foreach ($crlUrls as $crlUrl) {
66+
try {
67+
$isLocal = $this->isLocalCrlUrl($crlUrl);
68+
// Skip external CRL validation when disabled by admin, but always
69+
// validate local LibreSign-managed CRLs.
70+
if (!$externalValidationEnabled && !$isLocal) {
71+
$disabledUrls++;
72+
continue;
73+
}
74+
$validationResult = $this->downloadAndValidateWithDetails($crlUrl, $certPem, $isLocal);
75+
if ($validationResult['status'] === CrlValidationStatus::VALID) {
76+
return $validationResult;
77+
}
78+
if ($validationResult['status'] === CrlValidationStatus::REVOKED) {
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) {
84+
$accessibleUrls++;
85+
}
86+
} catch (\Exception $e) {
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) {
94+
return ['status' => CrlValidationStatus::DISABLED];
95+
}
96+
97+
if ($accessibleUrls === 0) {
98+
return ['status' => CrlValidationStatus::URLS_INACCESSIBLE];
99+
}
100+
101+
return ['status' => CrlValidationStatus::VALIDATION_FAILED];
102+
}
103+
104+
private function downloadAndValidateWithDetails(string $crlUrl, string $certPem, bool $isLocal): array {
105+
try {
106+
if ($isLocal) {
107+
$crlContent = $this->generateLocalCrl($crlUrl);
108+
} elseif ($this->ldapDownloader->isLdapUrl($crlUrl)) {
109+
$crlContent = $this->ldapDownloader->download($crlUrl);
110+
} else {
111+
$crlContent = $this->downloadCrlContent($crlUrl);
112+
}
113+
114+
if (!$crlContent) {
115+
throw new \Exception('Failed to get CRL content');
116+
}
117+
118+
return $this->checkCertificateInCrlWithDetails($certPem, $crlContent);
119+
120+
} catch (\Exception $e) {
121+
return ['status' => CrlValidationStatus::VALIDATION_ERROR];
122+
}
123+
}
124+
125+
private function isLocalCrlUrl(string $url): bool {
126+
$host = parse_url($url, PHP_URL_HOST);
127+
if (!$host) {
128+
return false;
129+
}
130+
131+
$trustedDomains = $this->config->getSystemValue('trusted_domains', []);
132+
133+
return in_array($host, $trustedDomains, true);
134+
}
135+
136+
private function generateLocalCrl(string $crlUrl): ?string {
137+
try {
138+
$pattern = $this->getLocalCrlPattern();
139+
if (preg_match($pattern, $crlUrl, $matches)) {
140+
$instanceId = $matches[1];
141+
$generation = (int)$matches[2];
142+
$engineType = $matches[3];
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);
148+
149+
return $crlService->generateCrlDer($instanceId, $generation, $engineType);
150+
}
151+
152+
$this->logger->debug('CRL URL does not match expected pattern', ['url' => $crlUrl, 'pattern' => $pattern]);
153+
return null;
154+
} catch (\Exception $e) {
155+
$this->logger->warning('Failed to generate local CRL: ' . $e->getMessage());
156+
return null;
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) {
168+
return $this->localCrlPattern;
169+
}
170+
171+
$templateUrl = $this->urlGenerator->linkToRouteAbsolute('libresign.crl.getRevocationList', [
172+
'instanceId' => 'INSTANCEID',
173+
'generation' => 999999,
174+
'engineType' => 'ENGINETYPE',
175+
]);
176+
177+
$patternUrl = str_replace('INSTANCEID', '([^/_]+)', $templateUrl);
178+
$patternUrl = str_replace('999999', '(\d+)', $patternUrl);
179+
$patternUrl = str_replace('ENGINETYPE', '([^/_]+)', $patternUrl);
180+
181+
$escapedPattern = str_replace([':', '/', '.'], ['\:', '\/', '\.'], $patternUrl);
182+
$escapedPattern = str_replace('\/apps\/', '(?:\/index\.php)?\/apps\/', $escapedPattern);
183+
184+
$this->localCrlPattern = '/^' . $escapedPattern . '$/';
185+
return $this->localCrlPattern;
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'])) {
190+
return null;
191+
}
192+
193+
$cacheKey = sha1($url);
194+
$cached = $this->cache->get($cacheKey);
195+
if ($cached !== null) {
196+
return $cached;
197+
}
198+
199+
$context = stream_context_create([
200+
'http' => [
201+
'timeout' => 30,
202+
'user_agent' => 'LibreSign/1.0 CRL Validator',
203+
'follow_location' => 1,
204+
'max_redirects' => 3,
205+
]
206+
]);
207+
208+
$content = @file_get_contents($url, false, $context);
209+
if ($content === false) {
210+
return null;
211+
}
212+
213+
$this->cache->set($cacheKey, $content, 86400);
214+
return $content;
215+
}
216+
217+
protected function isSerialNumberInCrl(string $crlText, string $serialNumber): bool {
218+
$normalizedSerial = strtoupper($serialNumber);
219+
$normalizedSerial = ltrim($normalizedSerial, '0') ?: '0';
220+
221+
return preg_match('/Serial Number: 0*' . preg_quote($normalizedSerial, '/') . '/', $crlText) === 1;
222+
}
223+
224+
private function checkCertificateInCrlWithDetails(string $certPem, string $crlContent): array {
225+
try {
226+
$certResource = openssl_x509_read($certPem);
227+
if (!$certResource) {
228+
return ['status' => CrlValidationStatus::VALIDATION_ERROR];
229+
}
230+
231+
$certData = openssl_x509_parse($certResource);
232+
if (!isset($certData['serialNumber'])) {
233+
return ['status' => CrlValidationStatus::VALIDATION_ERROR];
234+
}
235+
236+
$tempCrlFile = $this->tempManager->getTemporaryFile('.crl');
237+
file_put_contents($tempCrlFile, $crlContent);
238+
239+
try {
240+
[$output, $exitCode] = $this->execOpenSslCrl($tempCrlFile);
241+
242+
if ($exitCode !== 0) {
243+
return ['status' => CrlValidationStatus::VALIDATION_ERROR];
244+
}
245+
246+
$crlText = implode("\n", $output);
247+
$serialCandidates = [$certData['serialNumber']];
248+
if (!empty($certData['serialNumberHex'])) {
249+
$serialCandidates[] = $certData['serialNumberHex'];
250+
}
251+
252+
foreach ($serialCandidates as $serial) {
253+
if ($this->isSerialNumberInCrl($crlText, $serial)) {
254+
$revokedAt = $this->extractRevocationDateFromCrlText($crlText, $serialCandidates);
255+
return array_filter([
256+
'status' => CrlValidationStatus::REVOKED,
257+
'revoked_at' => $revokedAt,
258+
]);
259+
}
260+
}
261+
262+
return ['status' => CrlValidationStatus::VALID];
263+
264+
} finally {
265+
if (file_exists($tempCrlFile)) {
266+
unlink($tempCrlFile);
267+
}
268+
}
269+
270+
} catch (\Exception $e) {
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 {
281+
$cmd = sprintf(
282+
'openssl crl -in %s -inform DER -text -noout',
283+
escapeshellarg($tempCrlFile)
284+
);
285+
exec($cmd, $output, $exitCode);
286+
return [$output, $exitCode];
287+
}
288+
289+
protected function extractRevocationDateFromCrlText(string $crlText, array $serialNumbers): ?string {
290+
foreach ($serialNumbers as $serial) {
291+
$normalizedSerial = strtoupper(ltrim((string)$serial, '0')) ?: '0';
292+
$pattern = '/Serial Number:\s*0*' . preg_quote($normalizedSerial, '/') . '\s*\R\s*Revocation Date:\s*([^\r\n]+)/i';
293+
if (preg_match($pattern, $crlText, $matches) !== 1) {
294+
continue;
295+
}
296+
$dateText = trim($matches[1]);
297+
try {
298+
$date = new \DateTimeImmutable($dateText, new \DateTimeZone('UTC'));
299+
return $date->setTimezone(new \DateTimeZone('UTC'))->format(\DateTimeInterface::ATOM);
300+
} catch (\Exception $e) {
301+
continue;
302+
}
303+
}
304+
return null;
305+
}
306+
}

0 commit comments

Comments
 (0)