Skip to content

Commit 3675155

Browse files
authored
Merge pull request #7084 from LibreSign/backport/7081/stable33
[stable33] feat: crl revocation checker
2 parents 7705f6a + e6eff23 commit 3675155

21 files changed

Lines changed: 1390 additions & 331 deletions

lib/Enum/CrlValidationStatus.php

Lines changed: 33 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,33 @@
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\Enum;
11+
12+
/**
13+
* Represents the outcome of a CRL revocation check performed by
14+
* {@see \OCA\Libresign\Service\Crl\CrlRevocationChecker}.
15+
*/
16+
enum CrlValidationStatus: string {
17+
/** CRL fetched and certificate serial not listed as revoked. */
18+
case VALID = 'valid';
19+
/** Certificate serial found in the CRL – certificate is revoked. */
20+
case REVOKED = 'revoked';
21+
/** Admin disabled external CRL validation; local CRLs were not checked. */
22+
case DISABLED = 'disabled';
23+
/** All CRL Distribution Point URLs were unreachable. */
24+
case URLS_INACCESSIBLE = 'urls_inaccessible';
25+
/** A download or parse error occurred while fetching the CRL. */
26+
case VALIDATION_ERROR = 'validation_error';
27+
/** CRL was parsed but the check was inconclusive. */
28+
case VALIDATION_FAILED = 'validation_failed';
29+
/** The crlDistributionPoints extension is present but contains no URLs. */
30+
case NO_URLS = 'no_urls';
31+
/** The certificate has no crlDistributionPoints extension at all. */
32+
case MISSING = 'missing';
33+
}

lib/Handler/CertificateEngine/AEngineHandler.php

Lines changed: 6 additions & 243 deletions
Original file line numberDiff line numberDiff line change
@@ -9,13 +9,15 @@
99
namespace OCA\Libresign\Handler\CertificateEngine;
1010

1111
use OCA\Libresign\AppInfo\Application;
12+
use OCA\Libresign\Enum\CrlValidationStatus;
1213
use OCA\Libresign\Exception\EmptyCertificateException;
1314
use OCA\Libresign\Exception\InvalidPasswordException;
1415
use OCA\Libresign\Exception\LibresignException;
1516
use OCA\Libresign\Helper\ConfigureCheckHelper;
1617
use OCA\Libresign\Helper\MagicGetterSetterTrait;
1718
use OCA\Libresign\Service\CaIdentifierService;
1819
use OCA\Libresign\Service\CertificatePolicyService;
20+
use OCA\Libresign\Service\Crl\CrlRevocationChecker;
1921
use OCP\Files\AppData\IAppDataFactory;
2022
use OCP\Files\IAppData;
2123
use OCP\Files\SimpleFS\ISimpleFolder;
@@ -82,6 +84,7 @@ public function __construct(
8284
protected IURLGenerator $urlGenerator,
8385
protected CaIdentifierService $caIdentifierService,
8486
protected LoggerInterface $logger,
87+
private CrlRevocationChecker $crlRevocationChecker,
8588
) {
8689
$this->appData = $appDataFactory->get('libresign');
8790
}
@@ -180,19 +183,17 @@ private function parseX509(string $x509): array {
180183

181184
private function addCrlValidationInfo(array &$certData, string $certPem): void {
182185
if (isset($certData['extensions']['crlDistributionPoints'])) {
183-
$crlDistributionPoints = $certData['extensions']['crlDistributionPoints'];
184-
185-
preg_match_all('/URI:([^\s,\n]+)/', $crlDistributionPoints, $matches);
186+
preg_match_all('/URI:([^\s,\n]+)/', $certData['extensions']['crlDistributionPoints'], $matches);
186187
$extractedUrls = $matches[1] ?? [];
187188

188189
$certData['crl_urls'] = $extractedUrls;
189-
$crlDetails = $this->validateCrlFromUrlsWithDetails($extractedUrls, $certPem);
190+
$crlDetails = $this->crlRevocationChecker->validate($extractedUrls, $certPem);
190191
$certData['crl_validation'] = $crlDetails['status'];
191192
if (!empty($crlDetails['revoked_at'])) {
192193
$certData['crl_revoked_at'] = $crlDetails['revoked_at'];
193194
}
194195
} else {
195-
$certData['crl_validation'] = 'missing';
196+
$certData['crl_validation'] = CrlValidationStatus::MISSING;
196197
$certData['crl_urls'] = [];
197198
}
198199
}
@@ -788,244 +789,6 @@ protected function getCrlDistributionUrl(): string {
788789
]);
789790
}
790791

791-
private function validateCrlFromUrls(array $crlUrls, string $certPem): string {
792-
$details = $this->validateCrlFromUrlsWithDetails($crlUrls, $certPem);
793-
return $details['status'];
794-
}
795-
796-
private function validateCrlFromUrlsWithDetails(array $crlUrls, string $certPem): array {
797-
if (empty($crlUrls)) {
798-
return ['status' => 'no_urls'];
799-
}
800-
801-
$accessibleUrls = 0;
802-
foreach ($crlUrls as $crlUrl) {
803-
try {
804-
$validationResult = $this->downloadAndValidateCrlWithDetails($crlUrl, $certPem);
805-
if ($validationResult['status'] === 'valid') {
806-
return $validationResult;
807-
}
808-
if ($validationResult['status'] === 'revoked') {
809-
return $validationResult;
810-
}
811-
$accessibleUrls++;
812-
} catch (\Exception $e) {
813-
continue;
814-
}
815-
}
816-
817-
if ($accessibleUrls === 0) {
818-
return ['status' => 'urls_inaccessible'];
819-
}
820-
821-
return ['status' => 'validation_failed'];
822-
}
823-
824-
private function downloadAndValidateCrl(string $crlUrl, string $certPem): string {
825-
try {
826-
if ($this->isLocalCrlUrl($crlUrl)) {
827-
$crlContent = $this->generateLocalCrl($crlUrl);
828-
} else {
829-
$crlContent = $this->downloadCrlContent($crlUrl);
830-
}
831-
832-
if (!$crlContent) {
833-
throw new \Exception('Failed to get CRL content');
834-
}
835-
836-
return $this->checkCertificateInCrl($certPem, $crlContent);
837-
838-
} catch (\Exception $e) {
839-
return 'validation_error';
840-
}
841-
}
842-
843-
private function downloadAndValidateCrlWithDetails(string $crlUrl, string $certPem): array {
844-
try {
845-
if ($this->isLocalCrlUrl($crlUrl)) {
846-
$crlContent = $this->generateLocalCrl($crlUrl);
847-
} else {
848-
$crlContent = $this->downloadCrlContent($crlUrl);
849-
}
850-
851-
if (!$crlContent) {
852-
throw new \Exception('Failed to get CRL content');
853-
}
854-
855-
return $this->checkCertificateInCrlWithDetails($certPem, $crlContent);
856-
857-
} catch (\Exception $e) {
858-
return ['status' => 'validation_error'];
859-
}
860-
}
861-
862-
private function isLocalCrlUrl(string $url): bool {
863-
$host = parse_url($url, PHP_URL_HOST);
864-
if (!$host) {
865-
return false;
866-
}
867-
868-
$trustedDomains = $this->config->getSystemValue('trusted_domains', []);
869-
870-
return in_array($host, $trustedDomains, true);
871-
}
872-
873-
private function generateLocalCrl(string $crlUrl): ?string {
874-
try {
875-
$templateUrl = $this->urlGenerator->linkToRouteAbsolute('libresign.crl.getRevocationList', [
876-
'instanceId' => 'INSTANCEID',
877-
'generation' => 999999,
878-
'engineType' => 'ENGINETYPE',
879-
]);
880-
881-
$patternUrl = str_replace('INSTANCEID', '([^/_]+)', $templateUrl);
882-
$patternUrl = str_replace('999999', '(\d+)', $patternUrl);
883-
$patternUrl = str_replace('ENGINETYPE', '([^/_]+)', $patternUrl);
884-
885-
$escapedPattern = str_replace([':', '/', '.'], ['\:', '\/', '\.'], $patternUrl);
886-
887-
$escapedPattern = str_replace('\/apps\/', '(?:\/index\.php)?\/apps\/', $escapedPattern);
888-
889-
$pattern = '/^' . $escapedPattern . '$/';
890-
if (preg_match($pattern, $crlUrl, $matches)) {
891-
$instanceId = $matches[1];
892-
$generation = (int)$matches[2];
893-
$engineType = $matches[3];
894-
895-
/** @var \OCA\Libresign\Service\Crl\CrlService */
896-
$crlService = \OC::$server->get(\OCA\Libresign\Service\Crl\CrlService::class);
897-
898-
$crlData = $crlService->generateCrlDer($instanceId, $generation, $engineType);
899-
900-
return $crlData;
901-
}
902-
903-
$this->logger->debug('CRL URL does not match expected pattern', ['url' => $crlUrl, 'pattern' => $pattern]);
904-
return null;
905-
} catch (\Exception $e) {
906-
$this->logger->warning('Failed to generate local CRL: ' . $e->getMessage());
907-
return null;
908-
}
909-
}
910-
911-
private function downloadCrlContent(string $url): ?string {
912-
if (!filter_var($url, FILTER_VALIDATE_URL) || !in_array(parse_url($url, PHP_URL_SCHEME), ['http', 'https'])) {
913-
return null;
914-
}
915-
916-
$context = stream_context_create([
917-
'http' => [
918-
'timeout' => 30,
919-
'user_agent' => 'LibreSign/1.0 CRL Validator',
920-
'follow_location' => 1,
921-
'max_redirects' => 3,
922-
]
923-
]);
924-
925-
$content = @file_get_contents($url, false, $context);
926-
return $content !== false ? $content : null;
927-
}
928-
929-
private function isSerialNumberInCrl(string $crlText, string $serialNumber): bool {
930-
$normalizedSerial = strtoupper($serialNumber);
931-
$normalizedSerial = ltrim($normalizedSerial, '0') ?: '0';
932-
933-
return preg_match('/Serial Number: 0*' . preg_quote($normalizedSerial, '/') . '/', $crlText) === 1;
934-
}
935-
936-
private function checkCertificateInCrl(string $certPem, string $crlContent): string {
937-
try {
938-
$certResource = openssl_x509_read($certPem);
939-
if (!$certResource) {
940-
return 'validation_error';
941-
}
942-
943-
$certData = openssl_x509_parse($certResource);
944-
if (!isset($certData['serialNumber'])) {
945-
return 'validation_error';
946-
}
947-
948-
return $this->checkCertificateInCrlWithDetails($certPem, $crlContent)['status'];
949-
950-
} catch (\Exception $e) {
951-
return 'validation_error';
952-
}
953-
}
954-
955-
private function checkCertificateInCrlWithDetails(string $certPem, string $crlContent): array {
956-
try {
957-
$certResource = openssl_x509_read($certPem);
958-
if (!$certResource) {
959-
return ['status' => 'validation_error'];
960-
}
961-
962-
$certData = openssl_x509_parse($certResource);
963-
if (!isset($certData['serialNumber'])) {
964-
return ['status' => 'validation_error'];
965-
}
966-
967-
$tempCrlFile = $this->tempManager->getTemporaryFile('.crl');
968-
file_put_contents($tempCrlFile, $crlContent);
969-
970-
try {
971-
$crlTextCmd = sprintf(
972-
'openssl crl -in %s -inform DER -text -noout',
973-
escapeshellarg($tempCrlFile)
974-
);
975-
976-
exec($crlTextCmd, $output, $exitCode);
977-
978-
if ($exitCode !== 0) {
979-
return ['status' => 'validation_error'];
980-
}
981-
982-
$crlText = implode("\n", $output);
983-
$serialCandidates = [$certData['serialNumber']];
984-
if (!empty($certData['serialNumberHex'])) {
985-
$serialCandidates[] = $certData['serialNumberHex'];
986-
}
987-
988-
foreach ($serialCandidates as $serial) {
989-
if ($this->isSerialNumberInCrl($crlText, $serial)) {
990-
$revokedAt = $this->extractRevocationDateFromCrlText($crlText, $serialCandidates);
991-
return array_filter([
992-
'status' => 'revoked',
993-
'revoked_at' => $revokedAt,
994-
]);
995-
}
996-
}
997-
998-
return ['status' => 'valid'];
999-
1000-
} finally {
1001-
if (file_exists($tempCrlFile)) {
1002-
unlink($tempCrlFile);
1003-
}
1004-
}
1005-
1006-
} catch (\Exception $e) {
1007-
return ['status' => 'validation_error'];
1008-
}
1009-
}
1010-
1011-
private function extractRevocationDateFromCrlText(string $crlText, array $serialNumbers): ?string {
1012-
foreach ($serialNumbers as $serial) {
1013-
$normalizedSerial = strtoupper(ltrim((string)$serial, '0')) ?: '0';
1014-
$pattern = '/Serial Number:\s*0*' . preg_quote($normalizedSerial, '/') . '\s*\R\s*Revocation Date:\s*([^\r\n]+)/i';
1015-
if (preg_match($pattern, $crlText, $matches) !== 1) {
1016-
continue;
1017-
}
1018-
$dateText = trim($matches[1]);
1019-
try {
1020-
$date = new \DateTimeImmutable($dateText, new \DateTimeZone('UTC'));
1021-
return $date->setTimezone(new \DateTimeZone('UTC'))->format(\DateTimeInterface::ATOM);
1022-
} catch (\Exception $e) {
1023-
continue;
1024-
}
1025-
}
1026-
return null;
1027-
}
1028-
1029792
#[\Override]
1030793
public function generateCrlDer(array $revokedCertificates, string $instanceId, int $generation, int $crlNumber): string {
1031794
$configPath = $this->getConfigPathByParams($instanceId, $generation);

lib/Handler/CertificateEngine/CfsslHandler.php

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -20,6 +20,7 @@
2020
use OCA\Libresign\Helper\ConfigureCheckHelper;
2121
use OCA\Libresign\Service\CaIdentifierService;
2222
use OCA\Libresign\Service\CertificatePolicyService;
23+
use OCA\Libresign\Service\Crl\CrlRevocationChecker;
2324
use OCA\Libresign\Service\Install\InstallService;
2425
use OCP\Files\AppData\IAppDataFactory;
2526
use OCP\IAppConfig;
@@ -56,6 +57,7 @@ public function __construct(
5657
protected CaIdentifierService $caIdentifierService,
5758
protected CrlMapper $crlMapper,
5859
protected LoggerInterface $logger,
60+
CrlRevocationChecker $crlRevocationChecker,
5961
) {
6062
parent::__construct(
6163
$config,
@@ -67,6 +69,7 @@ public function __construct(
6769
$urlGenerator,
6870
$caIdentifierService,
6971
$logger,
72+
$crlRevocationChecker,
7073
);
7174

7275
$this->cfsslServerHandler->configCallback(fn () => $this->getCurrentConfigPath());

lib/Handler/CertificateEngine/OpenSslHandler.php

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -14,6 +14,7 @@
1414
use OCA\Libresign\Exception\LibresignException;
1515
use OCA\Libresign\Service\CaIdentifierService;
1616
use OCA\Libresign\Service\CertificatePolicyService;
17+
use OCA\Libresign\Service\Crl\CrlRevocationChecker;
1718
use OCA\Libresign\Service\SerialNumberService;
1819
use OCA\Libresign\Service\SubjectAlternativeNameService;
1920
use OCP\Files\AppData\IAppDataFactory;
@@ -45,6 +46,7 @@ public function __construct(
4546
protected LoggerInterface $logger,
4647
protected CrlMapper $crlMapper,
4748
protected SubjectAlternativeNameService $subjectAlternativeNameService,
49+
CrlRevocationChecker $crlRevocationChecker,
4850
) {
4951
parent::__construct(
5052
$config,
@@ -56,6 +58,7 @@ public function __construct(
5658
$urlGenerator,
5759
$caIdentifierService,
5860
$logger,
61+
$crlRevocationChecker,
5962
);
6063
}
6164

0 commit comments

Comments
 (0)