Code Coverage
 
Lines
Functions and Methods
Classes and Traits
Total
96.05% covered (success)
96.05%
73 / 76
83.33% covered (warning)
83.33%
5 / 6
CRAP
0.00% covered (danger)
0.00%
0 / 1
LdapCrlDownloader
96.05% covered (success)
96.05%
73 / 76
83.33% covered (warning)
83.33%
5 / 6
30
0.00% covered (danger)
0.00%
0 / 1
 __construct
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 isLdapUrl
100.00% covered (success)
100.00%
5 / 5
100.00% covered (success)
100.00%
1 / 1
2
 download
100.00% covered (success)
100.00%
8 / 8
100.00% covered (success)
100.00%
1 / 1
4
 encodeCacheableBinaryContent
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 decodeCachedBinaryContent
100.00% covered (success)
100.00%
4 / 4
100.00% covered (success)
100.00%
1 / 1
3
 fetchFromLdap
94.74% covered (success)
94.74%
54 / 57
0.00% covered (danger)
0.00%
0 / 1
19.05
1<?php
2
3declare(strict_types=1);
4
5/**
6 * SPDX-FileCopyrightText: 2026 LibreCode coop and contributors
7 * SPDX-License-Identifier: AGPL-3.0-or-later
8 */
9
10namespace OCA\Libresign\Service\Crl\Ldap;
11
12use OCP\ICache;
13use OCP\ICacheFactory;
14use 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 */
30class LdapCrlDownloader {
31    private const BINARY_CACHE_PREFIX = 'base64:';
32
33    private ICache $cache;
34
35    public function __construct(
36        private LoggerInterface $logger,
37        ICacheFactory $cacheFactory,
38        private ILdapConnection $ldap = new PhpLdapConnection(),
39    ) {
40        $this->cache = $cacheFactory->createDistributed('libresign_crl_ldap');
41    }
42
43    public function isLdapUrl(string $url): bool {
44        $scheme = parse_url($url, PHP_URL_SCHEME);
45        if (!is_string($scheme)) {
46            return preg_match('/^ldaps?:\/\//i', trim($url)) === 1;
47        }
48
49        $scheme = strtolower($scheme);
50        return in_array($scheme, ['ldap', 'ldaps'], true);
51    }
52
53    public function download(string $url): ?string {
54        $cacheKey = sha1($url);
55        $cached = $this->cache->get($cacheKey);
56        if ($cached !== null) {
57            return is_string($cached) ? $this->decodeCachedBinaryContent($cached) : null;
58        }
59
60        $content = $this->fetchFromLdap($url);
61        if ($content !== null) {
62            $this->cache->set($cacheKey, $this->encodeCacheableBinaryContent($content), 86400);
63        }
64        return $content;
65    }
66
67    private function encodeCacheableBinaryContent(string $content): string {
68        return self::BINARY_CACHE_PREFIX . base64_encode($content);
69    }
70
71    private function decodeCachedBinaryContent(string $cachedContent): string {
72        if (!str_starts_with($cachedContent, self::BINARY_CACHE_PREFIX)) {
73            return $cachedContent;
74        }
75
76        $decoded = base64_decode(substr($cachedContent, strlen(self::BINARY_CACHE_PREFIX)), true);
77        return $decoded === false ? $cachedContent : $decoded;
78    }
79
80    private function fetchFromLdap(string $url): ?string {
81        $parsed = parse_url($url);
82        if (!$parsed) {
83            return null;
84        }
85
86        $host = $parsed['host'] ?? null;
87        $scheme = strtolower($parsed['scheme'] ?? 'ldap');
88        $port = $parsed['port'] ?? ($scheme === 'ldaps' ? 636 : 389);
89        $dn = isset($parsed['path']) ? urldecode(ltrim($parsed['path'], '/')) : '';
90
91        if (!$host || !$dn) {
92            $this->logger->warning('Invalid LDAP URL for CRL retrieval: missing host or DN', ['url' => $url]);
93            return null;
94        }
95
96        // LDAP URL query components: attributes?scope?filter (RFC 4516)
97        $queryParts = isset($parsed['query']) ? explode('?', $parsed['query']) : [];
98        $attributeStr = $queryParts[0] ?? 'certificateRevocationList';
99        $scope = strtolower($queryParts[1] ?? 'base');
100        $filter = $queryParts[2] ?? '(objectClass=*)';
101
102        // Strip option suffixes like ;binary from attribute names for the LDAP query
103        $attributes = array_values(array_filter(
104            array_map(fn (string $attr) => explode(';', trim($attr))[0], explode(',', $attributeStr))
105        ));
106        if (empty($attributes)) {
107            $attributes = ['certificateRevocationList'];
108        }
109
110        // Ensure filter is wrapped in parentheses as required by LDAP
111        if (empty($filter) || $filter === '*') {
112            $filter = '(objectClass=*)';
113        } elseif (!str_starts_with($filter, '(')) {
114            $filter = '(' . $filter . ')';
115        }
116
117        $ldapConn = null;
118        try {
119            $ldapConn = $this->ldap->connect($host, $port);
120
121            $this->ldap->configure($ldapConn);
122
123            // Anonymous bind (CRL endpoints are publicly readable)
124            $this->ldap->bind($ldapConn);
125
126            $result = match ($scope) {
127                'one', 'onelevel' => $this->ldap->listEntries($ldapConn, $dn, $filter, $attributes),
128                'sub', 'subtree' => $this->ldap->search($ldapConn, $dn, $filter, $attributes),
129                default => $this->ldap->read($ldapConn, $dn, $filter, $attributes),
130            };
131
132            if (!$result) {
133                $this->logger->warning('LDAP search returned no result for CRL retrieval', [
134                    'dn' => $dn,
135                    'filter' => $filter,
136                    'scope' => $scope,
137                ]);
138                return null;
139            }
140
141            $entries = $this->ldap->getEntries($ldapConn, $result);
142
143            if (empty($entries) || ($entries['count'] ?? 0) === 0) {
144                return null;
145            }
146
147            foreach ($attributes as $attr) {
148                $attrLower = strtolower($attr);
149                /** @psalm-suppress InvalidArrayOffset */
150                if (!empty($entries[0][$attrLower][0])) {
151                    return $entries[0][$attrLower][0];
152                }
153            }
154
155            return null;
156        } catch (\RuntimeException $e) {
157            $this->logger->warning('Failed to connect to LDAP server for CRL retrieval: ' . $e->getMessage(), [
158                'url' => $url,
159            ]);
160            return null;
161        } catch (\Exception $e) {
162            $this->logger->warning('Failed to retrieve CRL via LDAP: ' . $e->getMessage(), ['url' => $url]);
163            return null;
164        } finally {
165            if ($ldapConn !== null) {
166                $this->ldap->unbind($ldapConn);
167            }
168        }
169    }
170}