Skip to content

Commit 77507a0

Browse files
committed
feat(crl): add LdapCrlDownloader for LDAP-hosted CRL distribution points
Resolves ldap:// or ldaps:// CRL URLs, negotiates LDAPv3 anonymous bind, reads the certificateRevocationList attribute and returns the raw DER bytes. Results are cached for 24 h via ICacheFactory. A finally block guarantees ldap_unbind even when getEntries throws. Signed-off-by: Vitor Mattos <1079143+vitormattos@users.noreply.github.com>
1 parent c483696 commit 77507a0

1 file changed

Lines changed: 150 additions & 0 deletions

File tree

Lines changed: 150 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,150 @@
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\Ldap;
11+
12+
use OCP\ICache;
13+
use OCP\ICacheFactory;
14+
use Psr\Log\LoggerInterface;
15+
16+
/**
17+
* Downloads CRL content from LDAP-based CRL Distribution Points.
18+
*
19+
* Handles LDAP URLs as defined in RFC 2255 / RFC 4516, e.g.:
20+
* ldap://host/dn?attributes?scope?filter
21+
*
22+
* This enables validation of government-issued certificates that publish
23+
* their CRL via LDAP instead of HTTP. Results are cached for 24 h to avoid
24+
* repeated LDAP round-trips during the same day.
25+
*
26+
* The actual LDAP I/O is delegated to an {@see ILdapConnection} so that
27+
* tests can inject a mock without requiring a live LDAP server or the PHP
28+
* ldap extension.
29+
*/
30+
class LdapCrlDownloader {
31+
private ICache $cache;
32+
33+
public function __construct(
34+
private LoggerInterface $logger,
35+
ICacheFactory $cacheFactory,
36+
private ILdapConnection $ldap = new PhpLdapConnection(),
37+
) {
38+
$this->cache = $cacheFactory->createDistributed('libresign_crl_ldap');
39+
}
40+
41+
public function isLdapUrl(string $url): bool {
42+
$scheme = strtolower(parse_url($url, PHP_URL_SCHEME) ?? '');
43+
return in_array($scheme, ['ldap', 'ldaps'], true);
44+
}
45+
46+
public function download(string $url): ?string {
47+
$cacheKey = sha1($url);
48+
$cached = $this->cache->get($cacheKey);
49+
if ($cached !== null) {
50+
return $cached;
51+
}
52+
53+
$content = $this->fetchFromLdap($url);
54+
if ($content !== null) {
55+
$this->cache->set($cacheKey, $content, 86400);
56+
}
57+
return $content;
58+
}
59+
60+
private function fetchFromLdap(string $url): ?string {
61+
$parsed = parse_url($url);
62+
if (!$parsed) {
63+
return null;
64+
}
65+
66+
$host = $parsed['host'] ?? null;
67+
$scheme = strtolower($parsed['scheme'] ?? 'ldap');
68+
$port = $parsed['port'] ?? ($scheme === 'ldaps' ? 636 : 389);
69+
$dn = isset($parsed['path']) ? urldecode(ltrim($parsed['path'], '/')) : '';
70+
71+
if (!$host || !$dn) {
72+
$this->logger->warning('Invalid LDAP URL for CRL retrieval: missing host or DN', ['url' => $url]);
73+
return null;
74+
}
75+
76+
// LDAP URL query components: attributes?scope?filter (RFC 4516)
77+
$queryParts = isset($parsed['query']) ? explode('?', $parsed['query']) : [];
78+
$attributeStr = $queryParts[0] ?? 'certificateRevocationList';
79+
$scope = strtolower($queryParts[1] ?? 'base');
80+
$filter = $queryParts[2] ?? '(objectClass=*)';
81+
82+
// Strip option suffixes like ;binary from attribute names for the LDAP query
83+
$attributes = array_values(array_filter(
84+
array_map(fn (string $attr) => explode(';', trim($attr))[0], explode(',', $attributeStr))
85+
));
86+
if (empty($attributes)) {
87+
$attributes = ['certificateRevocationList'];
88+
}
89+
90+
// Ensure filter is wrapped in parentheses as required by LDAP
91+
if (empty($filter) || $filter === '*') {
92+
$filter = '(objectClass=*)';
93+
} elseif (!str_starts_with($filter, '(')) {
94+
$filter = '(' . $filter . ')';
95+
}
96+
97+
$ldapConn = null;
98+
try {
99+
$ldapConn = $this->ldap->connect($host, $port);
100+
101+
$this->ldap->configure($ldapConn);
102+
103+
// Anonymous bind (CRL endpoints are publicly readable)
104+
$this->ldap->bind($ldapConn);
105+
106+
$result = match ($scope) {
107+
'one', 'onelevel' => $this->ldap->listEntries($ldapConn, $dn, $filter, $attributes),
108+
'sub', 'subtree' => $this->ldap->search($ldapConn, $dn, $filter, $attributes),
109+
default => $this->ldap->read($ldapConn, $dn, $filter, $attributes),
110+
};
111+
112+
if (!$result) {
113+
$this->logger->warning('LDAP search returned no result for CRL retrieval', [
114+
'dn' => $dn,
115+
'filter' => $filter,
116+
'scope' => $scope,
117+
]);
118+
return null;
119+
}
120+
121+
$entries = $this->ldap->getEntries($ldapConn, $result);
122+
123+
if (empty($entries) || ($entries['count'] ?? 0) === 0) {
124+
return null;
125+
}
126+
127+
foreach ($attributes as $attr) {
128+
$attrLower = strtolower($attr);
129+
/** @psalm-suppress InvalidArrayOffset */
130+
if (!empty($entries[0][$attrLower][0])) {
131+
return $entries[0][$attrLower][0];
132+
}
133+
}
134+
135+
return null;
136+
} catch (\RuntimeException $e) {
137+
$this->logger->warning('Failed to connect to LDAP server for CRL retrieval: ' . $e->getMessage(), [
138+
'url' => $url,
139+
]);
140+
return null;
141+
} catch (\Exception $e) {
142+
$this->logger->warning('Failed to retrieve CRL via LDAP: ' . $e->getMessage(), ['url' => $url]);
143+
return null;
144+
} finally {
145+
if ($ldapConn !== null) {
146+
$this->ldap->unbind($ldapConn);
147+
}
148+
}
149+
}
150+
}

0 commit comments

Comments
 (0)