Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
40 changes: 25 additions & 15 deletions lib/Handler/CertificateEngine/AEngineHandler.php
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -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,
Expand All @@ -87,6 +89,7 @@ public function __construct(
private CrlRevocationChecker $crlRevocationChecker,
) {
$this->appData = $appDataFactory->get('libresign');
$this->crlDistributionPointsExtractor = new CrlDistributionPointsExtractor();
}

protected function exportToPkcs12(
Expand Down Expand Up @@ -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) {
Expand Down
90 changes: 90 additions & 0 deletions lib/Service/Crl/CrlDistributionPointsExtractor.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,90 @@
<?php

declare(strict_types=1);

/**
* SPDX-FileCopyrightText: 2026 LibreCode coop and contributors
* SPDX-License-Identifier: AGPL-3.0-or-later
*/

namespace OCA\Libresign\Service\Crl;

final class CrlDistributionPointsExtractor {
/** @var array<string, true> */
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<array-key, mixed> $extensions
* @return array{hasExtension: bool, urls: list<string>}
*/
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<string> $orderedUrls
* @param array<string, true> $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, ')]');
}
}
162 changes: 162 additions & 0 deletions tests/php/Unit/Service/Crl/CrlDistributionPointsExtractorTest.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,162 @@
<?php

declare(strict_types=1);

/**
* SPDX-FileCopyrightText: 2026 LibreCode coop and contributors
* SPDX-License-Identifier: AGPL-3.0-or-later
*/

namespace OCA\Libresign\Tests\Unit\Service\Crl;

use OCA\Libresign\Service\Crl\CrlDistributionPointsExtractor;
use PHPUnit\Framework\Attributes\DataProvider;
use PHPUnit\Framework\TestCase;

final class CrlDistributionPointsExtractorTest extends TestCase {
private CrlDistributionPointsExtractor $extractor;

protected function setUp(): void {
$this->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<string, array{0: array<string, mixed>, 1: bool, 2: list<string>}>
*/
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,
[],
],
];
}
}
Loading