diff --git a/lib/Handler/CertificateEngine/AEngineHandler.php b/lib/Handler/CertificateEngine/AEngineHandler.php index 6faf1bdd10..7aca24face 100644 --- a/lib/Handler/CertificateEngine/AEngineHandler.php +++ b/lib/Handler/CertificateEngine/AEngineHandler.php @@ -17,6 +17,7 @@ use OCA\Libresign\Helper\MagicGetterSetterTrait; use OCA\Libresign\Service\CaIdentifierService; use OCA\Libresign\Service\CertificatePolicyService; +use OCA\Libresign\Service\Crl\CrlDistributionPointsExtractor; use OCA\Libresign\Service\Crl\CrlRevocationChecker; use OCP\Files\AppData\IAppDataFactory; use OCP\Files\IAppData; @@ -73,6 +74,7 @@ abstract class AEngineHandler implements IEngineHandler { protected string $certificate = ''; protected string $currentCaId = ''; protected IAppData $appData; + private CrlDistributionPointsExtractor $crlDistributionPointsExtractor; public function __construct( protected IConfig $config, @@ -87,6 +89,7 @@ public function __construct( private CrlRevocationChecker $crlRevocationChecker, ) { $this->appData = $appDataFactory->get('libresign'); + $this->crlDistributionPointsExtractor = new CrlDistributionPointsExtractor(); } protected function exportToPkcs12( @@ -182,23 +185,30 @@ private function parseX509(string $x509): array { } private function addCrlValidationInfo(array &$certData, string $certPem): void { - if (isset($certData['extensions']['crlDistributionPoints'])) { - preg_match_all('/URI:([^\s,\n]+)/', $certData['extensions']['crlDistributionPoints'], $matches); - $extractedUrls = $matches[1] ?? []; - - $certData['crl_urls'] = $extractedUrls; - $crlDetails = $this->crlRevocationChecker->validate($extractedUrls, $certPem); - $certData['crl_validation'] = $crlDetails['status']; - if (!empty($crlDetails['revoked_at'])) { - $certData['crl_revoked_at'] = $crlDetails['revoked_at']; + $extensions = $certData['extensions'] ?? []; + if (is_array($extensions)) { + ['hasExtension' => $hasCrlExtension, 'urls' => $extractedUrls] = $this->crlDistributionPointsExtractor->extractFromExtensions($extensions); + if ($hasCrlExtension) { + $certData['crl_urls'] = $extractedUrls; + if (empty($extractedUrls)) { + $certData['crl_validation'] = CrlValidationStatus::NO_URLS; + return; + } + + $crlDetails = $this->crlRevocationChecker->validate($extractedUrls, $certPem); + $certData['crl_validation'] = $crlDetails['status']; + if (!empty($crlDetails['revoked_at'])) { + $certData['crl_revoked_at'] = $crlDetails['revoked_at']; + } + return; } - } else { - $externalValidationEnabled = $this->appConfig->getValueBool(Application::APP_ID, 'crl_external_validation_enabled', true); - $certData['crl_validation'] = $externalValidationEnabled - ? CrlValidationStatus::MISSING - : CrlValidationStatus::DISABLED; - $certData['crl_urls'] = []; } + + $externalValidationEnabled = $this->appConfig->getValueBool(Application::APP_ID, 'crl_external_validation_enabled', true); + $certData['crl_validation'] = $externalValidationEnabled + ? CrlValidationStatus::MISSING + : CrlValidationStatus::DISABLED; + $certData['crl_urls'] = []; } private static function convertArrayToUtf8($array) { diff --git a/lib/Service/Crl/CrlDistributionPointsExtractor.php b/lib/Service/Crl/CrlDistributionPointsExtractor.php new file mode 100644 index 0000000000..09e2a5d3c0 --- /dev/null +++ b/lib/Service/Crl/CrlDistributionPointsExtractor.php @@ -0,0 +1,90 @@ + */ + private const ACCEPTED_EXTENSION_NAMES = [ + 'crldistributionpoints' => true, + 'x509v3 crl distribution points' => true, + '2.5.29.31' => true, + ]; + + private const URI_PATTERN = '/URI\s*:\s*([^\s\n]+)/i'; + + /** + * @param array $extensions + * @return array{hasExtension: bool, urls: list} + */ + public function extractFromExtensions(array $extensions): array { + $hasCrlExtension = false; + $orderedUrls = []; + $seenUrls = []; + foreach ($extensions as $extensionName => $extensionValue) { + if (!is_string($extensionName)) { + continue; + } + + $normalizedName = strtolower(trim($extensionName)); + if (!isset(self::ACCEPTED_EXTENSION_NAMES[$normalizedName])) { + continue; + } + $hasCrlExtension = true; + + if (is_string($extensionValue)) { + $this->appendUrlsFromText($extensionValue, $orderedUrls, $seenUrls); + } elseif (is_array($extensionValue)) { + foreach ($extensionValue as $extensionPart) { + if (is_string($extensionPart)) { + $this->appendUrlsFromText($extensionPart, $orderedUrls, $seenUrls); + } + } + } + } + + if (!$hasCrlExtension) { + return ['hasExtension' => false, 'urls' => []]; + } + + return [ + 'hasExtension' => true, + 'urls' => $orderedUrls, + ]; + } + + /** + * @param list $orderedUrls + * @param array $seenUrls + */ + private function appendUrlsFromText(string $value, array &$orderedUrls, array &$seenUrls): void { + if (stripos($value, 'URI') === false) { + return; + } + + preg_match_all(self::URI_PATTERN, $value, $matches); + if (empty($matches[1])) { + return; + } + + foreach ($matches[1] as $url) { + $normalizedUrl = $this->normalizeUrlToken($url); + if ($normalizedUrl === '' || isset($seenUrls[$normalizedUrl])) { + continue; + } + + $seenUrls[$normalizedUrl] = true; + $orderedUrls[] = $normalizedUrl; + } + } + + private function normalizeUrlToken(string $url): string { + return rtrim($url, ')]'); + } +} diff --git a/tests/php/Unit/Service/Crl/CrlDistributionPointsExtractorTest.php b/tests/php/Unit/Service/Crl/CrlDistributionPointsExtractorTest.php new file mode 100644 index 0000000000..53f392394e --- /dev/null +++ b/tests/php/Unit/Service/Crl/CrlDistributionPointsExtractorTest.php @@ -0,0 +1,162 @@ +extractor = new CrlDistributionPointsExtractor(); + } + + #[DataProvider('crlDistributionPointExtractionProvider')] + public function testExtractFromExtensions(array $extensions, bool $expectedHasExtension, array $expectedUrls): void { + $result = $this->extractor->extractFromExtensions($extensions); + + $this->assertSame($expectedHasExtension, $result['hasExtension']); + $this->assertSame($expectedUrls, $result['urls']); + } + + /** + * RFC 5280 4.2.1.13 defines cRLDistributionPoints as DistributionPointName + * with URI represented in GeneralNames. Tests cover common OpenSSL textual + * outputs for HTTP and LDAP URIs and multiple distribution points. + * + * @return array, 1: bool, 2: list}> + */ + public static function crlDistributionPointExtractionProvider(): array { + return [ + 'oid-extension-with-http-uri' => [ + [ + '2.5.29.31' => "Full Name:\nURI:https://example.org/crl/root.crl", + ], + true, + ['https://example.org/crl/root.crl'], + ], + 'x509v3-label-with-http-uri' => [ + [ + 'X509v3 CRL Distribution Points' => "Full Name:\n URI : https://example.org/crl/issuer.crl", + ], + true, + ['https://example.org/crl/issuer.crl'], + ], + 'rfc-ldap-uri-with-dn-and-query' => [ + [ + 'crlDistributionPoints' => "Full Name:\nURI:ldap://ldap.example.com/cn=Example%20CA,ou=PKI,dc=example,dc=com?certificateRevocationList;binary", + ], + true, + ['ldap://ldap.example.com/cn=Example%20CA,ou=PKI,dc=example,dc=com?certificateRevocationList;binary'], + ], + 'multiple-distribution-points-in-single-extension' => [ + [ + '2.5.29.31' => "Full Name:\nURI:https://pki.example.org/root.crl\nFull Name:\nURI:ldap://ldap.example.org/cn=RootCA,dc=example,dc=org?certificateRevocationList;binary", + ], + true, + [ + 'https://pki.example.org/root.crl', + 'ldap://ldap.example.org/cn=RootCA,dc=example,dc=org?certificateRevocationList;binary', + ], + ], + 'rfc-structure-with-reasons-and-crl-issuer' => [ + [ + '2.5.29.31' => "Full Name:\n URI:http://crl.example.org/root.crl\nReasons: keyCompromise, cACompromise\nCRL Issuer:\n DirName:/C=BR/O=Example/CN=Example CRL Issuer", + ], + true, + ['http://crl.example.org/root.crl'], + ], + 'extension-name-is-trimmed-and-case-insensitive' => [ + [ + ' X509V3 CRL Distribution Points ' => "Full Name:\n URI:https://example.org/crl/mixed-case.crl", + ], + true, + ['https://example.org/crl/mixed-case.crl'], + ], + 'uri-token-is-case-insensitive' => [ + [ + '2.5.29.31' => "Full Name:\nuri:ldap://ldap.example.net/cn=CA,dc=example,dc=net?certificateRevocationList;binary", + ], + true, + ['ldap://ldap.example.net/cn=CA,dc=example,dc=net?certificateRevocationList;binary'], + ], + 'uri-with-tabs-and-extra-whitespace' => [ + [ + '2.5.29.31' => "Full Name:\n\tURI\t:\t https://example.org/crl/with-tabs.crl", + ], + true, + ['https://example.org/crl/with-tabs.crl'], + ], + 'uri-line-with-closing-parenthesis-from-formatted-output' => [ + [ + '2.5.29.31' => "Distribution Point (1):\nURI:https://example.org/crl/formatted.crl)", + ], + true, + ['https://example.org/crl/formatted.crl'], + ], + 'multiple-supported-extension-keys-are-merged-and-deduplicated' => [ + [ + '2.5.29.31' => "Full Name:\nURI:https://example.org/crl/shared.crl", + 'crlDistributionPoints' => "Full Name:\nURI:https://example.org/crl/shared.crl\nURI:https://example.org/crl/extra.crl", + ], + true, + [ + 'https://example.org/crl/shared.crl', + 'https://example.org/crl/extra.crl', + ], + ], + 'array-extension-value-and-duplicates' => [ + [ + '2.5.29.31' => [ + 'Full Name:', + 'URI:https://example.org/crl/root.crl', + 'URI:https://example.org/crl/root.crl', + ], + ], + true, + ['https://example.org/crl/root.crl'], + ], + 'known-extension-without-uri' => [ + [ + '2.5.29.31' => 'Distribution Point Name: relativeName=CN=DP1', + ], + true, + [], + ], + 'known-extension-with-general-names-but-no-uri' => [ + [ + 'X509v3 CRL Distribution Points' => "Full Name:\nDNS:crl.example.org\nDirName:/C=BR/O=Example/CN=CRL Directory", + ], + true, + [], + ], + 'multiple-supported-keys-preserve-first-seen-order' => [ + [ + 'crlDistributionPoints' => "Full Name:\nURI:https://example.org/crl/first.crl", + '2.5.29.31' => "Full Name:\nURI:https://example.org/crl/second.crl", + ], + true, + [ + 'https://example.org/crl/first.crl', + 'https://example.org/crl/second.crl', + ], + ], + 'unknown-extension-name-should-not-match' => [ + [ + 'Issuer CRL Distribution Points' => "Full Name:\nURI:https://example.org/crl/issuer.crl", + ], + false, + [], + ], + ]; + } +}