Code Coverage
 
Lines
Functions and Methods
Classes and Traits
Total
86.25% covered (warning)
86.25%
251 / 291
63.33% covered (warning)
63.33%
19 / 30
CRAP
0.00% covered (danger)
0.00%
0 / 1
CrlService
86.25% covered (warning)
86.25%
251 / 291
63.33% covered (warning)
63.33%
19 / 30
113.98
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
 getRootCertificateFromCrlUrls
100.00% covered (success)
100.00%
13 / 13
100.00% covered (success)
100.00%
1 / 1
4
 revokeCertificate
100.00% covered (success)
100.00%
25 / 25
100.00% covered (success)
100.00%
1 / 1
3
 revokeUserCertificates
100.00% covered (success)
100.00%
7 / 7
100.00% covered (success)
100.00%
1 / 1
1
 revokeCertificateList
100.00% covered (success)
100.00%
24 / 24
100.00% covered (success)
100.00%
1 / 1
4
 getCertificateStatus
31.58% covered (danger)
31.58%
6 / 19
0.00% covered (danger)
0.00%
0 / 1
17.53
 getCertificateStatusResponse
60.00% covered (warning)
60.00%
9 / 15
0.00% covered (danger)
0.00%
0 / 1
8.30
 isInvalidAt
0.00% covered (danger)
0.00%
0 / 1
0.00% covered (danger)
0.00%
0 / 1
2
 getRevokedCertificates
100.00% covered (success)
100.00%
14 / 14
100.00% covered (success)
100.00%
1 / 1
3
 getCrlMetadata
100.00% covered (success)
100.00%
10 / 10
100.00% covered (success)
100.00%
1 / 1
4
 getNextCrlNumber
100.00% covered (success)
100.00%
6 / 6
100.00% covered (success)
100.00%
1 / 1
1
 generatedCrlCacheKey
100.00% covered (success)
100.00%
2 / 2
100.00% covered (success)
100.00%
1 / 1
1
 invalidateGeneratedCrlCache
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 normalizeEngineType
75.00% covered (warning)
75.00%
3 / 4
0.00% covered (danger)
0.00%
0 / 1
2.06
 cleanupExpiredCertificates
0.00% covered (danger)
0.00%
0 / 1
0.00% covered (danger)
0.00%
0 / 1
2
 getStatistics
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 getRevocationStatistics
0.00% covered (danger)
0.00%
0 / 1
0.00% covered (danger)
0.00%
0 / 1
2
 refreshGeneratedCrlCache
62.50% covered (warning)
62.50%
10 / 16
0.00% covered (danger)
0.00%
0 / 1
3.47
 generateCrlDer
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 refreshGeneratedCrlDer
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 resolveGeneratedCrlDer
90.00% covered (success)
90.00%
27 / 30
0.00% covered (danger)
0.00%
0 / 1
21.44
 waitForPersistedGeneratedCrl
100.00% covered (success)
100.00%
6 / 6
100.00% covered (success)
100.00%
1 / 1
5
 readPersistedGeneratedCrl
100.00% covered (success)
100.00%
11 / 11
100.00% covered (success)
100.00%
1 / 1
3
 isPersistedGeneratedCrlFresh
90.00% covered (success)
90.00%
9 / 10
0.00% covered (danger)
0.00%
0 / 1
4.02
 getPersistedGeneratedCrlTimestamp
100.00% covered (success)
100.00%
6 / 6
100.00% covered (success)
100.00%
1 / 1
4
 buildAndPersistGeneratedCrlDer
76.00% covered (warning)
76.00%
19 / 25
0.00% covered (danger)
0.00%
0 / 1
4.22
 getCurrentRefreshDate
100.00% covered (success)
100.00%
2 / 2
100.00% covered (success)
100.00%
1 / 1
1
 buildCrlDer
83.33% covered (warning)
83.33%
5 / 6
0.00% covered (danger)
0.00%
0 / 1
2.02
 listCrlEntries
100.00% covered (success)
100.00%
14 / 14
100.00% covered (success)
100.00%
1 / 1
1
 formatCrlListEntry
100.00% covered (success)
100.00%
18 / 18
100.00% covered (success)
100.00%
1 / 1
1
1<?php
2
3declare(strict_types=1);
4
5/**
6 * SPDX-FileCopyrightText: 2025 LibreCode coop and contributors
7 * SPDX-License-Identifier: AGPL-3.0-or-later
8 */
9
10namespace OCA\Libresign\Service\Crl;
11
12use DateTime;
13use OCA\Libresign\Db\Crl as CrlEntity;
14use OCA\Libresign\Db\CrlMapper;
15use OCA\Libresign\Enum\CertificateEngineType;
16use OCA\Libresign\Enum\CRLReason;
17use OCA\Libresign\Enum\CRLStatus;
18use OCA\Libresign\Exception\CrlGenerationInProgressException;
19use OCA\Libresign\Handler\CertificateEngine\CertificateEngineFactory;
20use OCA\Libresign\Service\Certificate\FileService;
21use OCP\ICacheFactory;
22use OCP\IMemcache;
23use Psr\Log\LoggerInterface;
24
25/**
26 * @psalm-import-type LibresignCrlCertificateStatusResponse from \OCA\Libresign\ResponseDefinitions
27 * @psalm-import-type LibresignCrlListItem from \OCA\Libresign\ResponseDefinitions
28 * @psalm-import-type LibresignCrlListResponse from \OCA\Libresign\ResponseDefinitions
29 * @psalm-type LibresignCrlStatusInfo = array{status: 'valid'}|array{status: 'unknown'}|array{status: 'expired', valid_to?: string}|array{status: 'revoked', reason_code?: null|int, revoked_at?: string}
30 *
31 * RFC 5280 compliant CRL management
32 */
33class CrlService {
34    /**
35     * Short-lived distributed lock used only to avoid CRL regeneration stampedes.
36     */
37    private IMemcache $lockCache;
38
39    /** How long (seconds) a generated CRL remains reusable as a stale fallback.
40     *
41     * Must be ≤ the CRL's own nextUpdate window, which AEngineHandler sets to
42     * 7 days (+7 days in createAndSignCrl).
43     */
44    private const GENERATED_CRL_MAX_STALE_AGE = 7 * 86400; // 7 days
45    private const GENERATED_CRL_REFRESH_DATE_FORMAT = 'Y-m-d';
46    private const GENERATED_CRL_LOCK_TTL = 60;
47    private const GENERATED_CRL_LOCK_WAIT_USEC = 100000;
48    private const GENERATED_CRL_LOCK_WAIT_ATTEMPTS = 10;
49
50    public function __construct(
51        private CrlMapper $crlMapper,
52        private LoggerInterface $logger,
53        private CertificateEngineFactory $certificateEngineFactory,
54        private CrlUrlParserService $crlUrlParserService,
55        private FileService $certificateFileService,
56        ICacheFactory $cacheFactory,
57        private GeneratedCrlStorageService $generatedCrlStorage,
58    ) {
59        $this->lockCache = $cacheFactory->createLocking('libresign_crl_generated');
60    }
61
62    public function getRootCertificateFromCrlUrls(array $crlUrls): string {
63        foreach ($crlUrls as $crlUrl) {
64            $crlInfo = $this->crlUrlParserService->parseUrl($crlUrl);
65            if ($crlInfo === null) {
66                continue;
67            }
68            $engineType = CertificateEngineType::tryFromValue($crlInfo['engineType']);
69            if ($engineType === null) {
70                continue;
71            }
72            return $this->certificateFileService->getRootCertificateByGeneration(
73                $crlInfo['instanceId'],
74                $crlInfo['generation'],
75                $engineType
76            );
77        }
78
79        return '';
80    }
81
82    public function revokeCertificate(
83        string $serialNumber,
84        CRLReason $reason = CRLReason::UNSPECIFIED,
85        ?string $reasonText = null,
86        ?string $revokedBy = null,
87        ?DateTime $invalidityDate = null,
88        ?DateTime $revokedAt = null,
89    ): bool {
90
91        try {
92            $certificate = $this->crlMapper->findBySerialNumber($serialNumber);
93            ['instanceId' => $instanceId, 'generation' => $generation, 'engineType' => $engineType] = $this->getCrlMetadata($certificate);
94            $crlNumber = $this->getNextCrlNumber($instanceId, $generation, $engineType);
95
96            $this->crlMapper->revokeCertificateEntity(
97                $certificate,
98                $reason,
99                $reasonText,
100                $revokedBy,
101                $invalidityDate,
102                $crlNumber,
103                $revokedAt,
104            );
105        } catch (\Exception $exception) {
106            $this->logger->warning('Failed to revoke certificate {serial}', [
107                'serial' => $serialNumber,
108                'error' => $exception->getMessage(),
109            ]);
110            return false;
111        }
112
113        // DB revocation succeeded. Invalidating the CRL cache is best-effort:
114        // markStale() handles its own write errors internally, but guard here
115        // too so an unexpected exception never rolls back a successful revocation.
116        try {
117            $this->invalidateGeneratedCrlCache($instanceId, $generation, $engineType);
118        } catch (\Exception $exception) {
119            $this->logger->debug('Failed to invalidate CRL cache after revocation (non-critical)', [
120                'serial' => $serialNumber,
121                'error' => $exception->getMessage(),
122            ]);
123        }
124
125        return true;
126    }
127
128    /**
129     * Revoke all issued certificates owned by a user
130     *
131     * @param string $userId User ID whose certificates should be revoked
132     * @param CRLReason $reason Revocation reason
133     * @param string|null $reasonText Optional text describing the reason
134     * @param string|null $revokedBy Who is revoking the certificates
135     * @return int Number of certificates revoked
136     */
137    public function revokeUserCertificates(
138        string $userId,
139        CRLReason $reason = CRLReason::UNSPECIFIED,
140        ?string $reasonText = null,
141        ?string $revokedBy = null,
142    ): int {
143        $certificates = $this->crlMapper->findIssuedByOwner($userId);
144
145        return $this->revokeCertificateList(
146            $certificates,
147            $reason,
148            $reasonText,
149            $revokedBy
150        );
151    }
152
153    /**
154     * Revoke a list of certificates
155     *
156     * @param array<\OCA\Libresign\Db\Crl> $certificates Array of Crl entities
157     * @param CRLReason $reason Revocation reason
158     * @param string|null $reasonText Optional text describing the reason
159     * @param string|null $revokedBy Who is revoking the certificates
160     * @return int Number of certificates successfully revoked
161     */
162    private function revokeCertificateList(
163        array $certificates,
164        CRLReason $reason,
165        ?string $reasonText = null,
166        ?string $revokedBy = null,
167    ): int {
168        $revokedCount = 0;
169        $invalidatedScopes = [];
170
171        foreach ($certificates as $certificate) {
172            try {
173                ['instanceId' => $instanceId, 'generation' => $generation, 'engineType' => $engineType] = $this->getCrlMetadata($certificate);
174                $crlNumber = $this->getNextCrlNumber($instanceId, $generation, $engineType);
175
176                $this->crlMapper->revokeCertificateEntity(
177                    $certificate,
178                    $reason,
179                    $reasonText,
180                    $revokedBy,
181                    null,
182                    $crlNumber
183                );
184
185                $scopeKey = $this->generatedCrlStorage->getScopeKey($instanceId, $generation, $engineType);
186                if (!isset($invalidatedScopes[$scopeKey])) {
187                    $this->invalidateGeneratedCrlCache($instanceId, $generation, $engineType);
188                    $invalidatedScopes[$scopeKey] = true;
189                }
190
191                $revokedCount++;
192            } catch (\Exception $e) {
193                $this->logger->warning('Failed to revoke certificate {serial}', [
194                    'serial' => $certificate->getSerialNumber(),
195                    'error' => $e->getMessage(),
196                ]);
197            }
198        }
199
200        return $revokedCount;
201    }
202
203    /**
204     * @return LibresignCrlStatusInfo
205     */
206    public function getCertificateStatus(string $serialNumber, ?DateTime $checkDate = null): array {
207        try {
208            $certificate = $this->crlMapper->findBySerialNumber($serialNumber);
209
210            if ($certificate->isRevoked()) {
211                $response = [
212                    'status' => 'revoked',
213                    'reason_code' => $certificate->getReasonCode(),
214                ];
215
216                $revokedAt = $certificate->getRevokedAt()?->format('Y-m-d\TH:i:s\Z');
217                if ($revokedAt !== null) {
218                    $response['revoked_at'] = $revokedAt;
219                }
220
221                return $response;
222            }
223
224            if ($certificate->isExpired()) {
225                $response = ['status' => 'expired'];
226
227                $validTo = $certificate->getValidTo()?->format('Y-m-d\TH:i:s\Z');
228                if ($validTo !== null) {
229                    $response['valid_to'] = $validTo;
230                }
231
232                return $response;
233            }
234
235            return ['status' => 'valid'];
236        } catch (\OCP\AppFramework\Db\DoesNotExistException) {
237            return ['status' => 'unknown'];
238        }
239    }
240
241    /**
242     * @return LibresignCrlCertificateStatusResponse
243     */
244    public function getCertificateStatusResponse(string $serialNumber): array {
245        $statusInfo = $this->getCertificateStatus($serialNumber);
246
247        /** @var LibresignCrlCertificateStatusResponse $response */
248        $response = [
249            'serial_number' => $serialNumber,
250            'status' => $statusInfo['status'],
251            'checked_at' => (new \DateTime())->format('Y-m-d\TH:i:s\Z'),
252        ];
253
254        if ($statusInfo['status'] === 'revoked') {
255            if (array_key_exists('reason_code', $statusInfo)) {
256                $response['reason_code'] = $statusInfo['reason_code'];
257            }
258            if (array_key_exists('revoked_at', $statusInfo)) {
259                $response['revoked_at'] = $statusInfo['revoked_at'];
260            }
261        }
262
263        if ($statusInfo['status'] === 'expired') {
264            if (array_key_exists('valid_to', $statusInfo)) {
265                $response['valid_to'] = $statusInfo['valid_to'];
266            }
267        }
268
269        return $response;
270    }
271
272    public function isInvalidAt(string $serialNumber, ?DateTime $checkDate = null): bool {
273        return $this->crlMapper->isInvalidAt($serialNumber, $checkDate);
274    }
275
276    public function getRevokedCertificates(string $instanceId = '', int $generation = 0, string $engine = ''): array {
277        $certificates = $this->crlMapper->getRevokedCertificates($instanceId, $generation, $engine);
278
279        $result = [];
280        foreach ($certificates as $certificate) {
281            $result[] = [
282                'serial_number' => $certificate->getSerialNumber(),
283                'owner' => $certificate->getOwner(),
284                'reason_code' => $certificate->getReasonCode(),
285                'description' => $certificate->getReasonCode() ? CRLReason::from($certificate->getReasonCode())->getDescription() : null,
286                'revoked_by' => $certificate->getRevokedBy(),
287                'revoked_at' => $certificate->getRevokedAt()?->format('Y-m-d H:i:s'),
288                'invalidity_date' => $certificate->getInvalidityDate()?->format('Y-m-d H:i:s'),
289                'crl_number' => $certificate->getCrlNumber(),
290            ];
291        }
292
293        return $result;
294    }
295
296    /**
297     * @return array{instanceId: string, generation: int, engineType: string}
298     */
299    private function getCrlMetadata(\OCA\Libresign\Db\Crl $certificate): array {
300        $instanceId = $certificate->getInstanceId();
301        $generation = $certificate->getGeneration();
302        $engineType = $certificate->getEngine();
303
304        if ($instanceId === null || $generation === null || $engineType === '') {
305            throw new \RuntimeException('Certificate missing CRL metadata: instance_id, generation or engine');
306        }
307
308        return [
309            'instanceId' => $instanceId,
310            'generation' => $generation,
311            'engineType' => $engineType,
312        ];
313    }
314
315    private function getNextCrlNumber(string $instanceId, int $generation, string $engineType): int {
316        $lastCrlNumber = $this->crlMapper->getLastCrlNumber(
317            $instanceId,
318            $generation,
319            $this->normalizeEngineType($engineType)->getEngineName()
320        );
321
322        return $lastCrlNumber + 1;
323    }
324
325    private function generatedCrlCacheKey(string $instanceId, int $generation, string $engineType): string {
326        $normalizedEngineType = $this->normalizeEngineType($engineType)->value;
327
328        return sha1($instanceId . '_' . $generation . '_' . $normalizedEngineType);
329    }
330
331    private function invalidateGeneratedCrlCache(string $instanceId, int $generation, string $engineType): void {
332        // Mark as stale rather than deleting: the CRL DER stays available for
333        // concurrent requests (isReusable = true) while one request regenerates.
334        // Deleting would force every concurrent validation to wait cold-start.
335        $this->generatedCrlStorage->markStale($instanceId, $generation, $engineType);
336    }
337
338    private function normalizeEngineType(string $engineType): CertificateEngineType {
339        $normalizedEngineType = CertificateEngineType::tryFromValue($engineType);
340        if ($normalizedEngineType === null) {
341            throw new \InvalidArgumentException("Invalid engine type: $engineType");
342        }
343
344        return $normalizedEngineType;
345    }
346
347    public function cleanupExpiredCertificates(?DateTime $before = null): int {
348        return $this->crlMapper->cleanupExpiredCertificates($before);
349    }
350
351    public function getStatistics(): array {
352        return $this->crlMapper->getStatistics();
353    }
354
355    public function getRevocationStatistics(): array {
356        return $this->crlMapper->getRevocationStatistics();
357    }
358
359    public function refreshGeneratedCrlCache(): int {
360        $refreshedScopes = 0;
361
362        foreach ($this->crlMapper->listGeneratedCrlScopes() as $scope) {
363            try {
364                $this->refreshGeneratedCrlDer(
365                    $scope['instanceId'],
366                    $scope['generation'],
367                    $scope['engineType']
368                );
369                $refreshedScopes++;
370            } catch (\Exception $exception) {
371                $this->logger->warning('Failed to refresh generated CRL cache for {instanceId}/{generation}/{engineType}', [
372                    'instanceId' => $scope['instanceId'],
373                    'generation' => $scope['generation'],
374                    'engineType' => $scope['engineType'],
375                    'error' => $exception->getMessage(),
376                ]);
377            }
378        }
379
380        return $refreshedScopes;
381    }
382
383    public function generateCrlDer(string $instanceId, int $generation, string $engineType): string {
384        return $this->resolveGeneratedCrlDer($instanceId, $generation, $engineType, false);
385    }
386
387    public function refreshGeneratedCrlDer(string $instanceId, int $generation, string $engineType): string {
388        return $this->resolveGeneratedCrlDer($instanceId, $generation, $engineType, true);
389    }
390
391    private function resolveGeneratedCrlDer(string $instanceId, int $generation, string $engineType, bool $forceRefresh): string {
392        $persistedCrl = $this->readPersistedGeneratedCrl($instanceId, $generation, $engineType);
393        if (!$forceRefresh && $persistedCrl !== null && $persistedCrl['isFresh']) {
394            return $persistedCrl['content'];
395        }
396
397        $lockKey = $this->generatedCrlCacheKey($instanceId, $generation, $engineType);
398        $lockToken = bin2hex(random_bytes(16));
399        $lockAcquired = $this->lockCache->add($lockKey, $lockToken, self::GENERATED_CRL_LOCK_TTL);
400
401        if (!$lockAcquired) {
402            $reloadedPersistedCrl = $this->readPersistedGeneratedCrl($instanceId, $generation, $engineType);
403            if ($reloadedPersistedCrl !== null && ($reloadedPersistedCrl['isFresh'] || (!$forceRefresh && $reloadedPersistedCrl['isReusable']))) {
404                return $reloadedPersistedCrl['content'];
405            }
406
407            if (!$forceRefresh && $persistedCrl !== null && $persistedCrl['isReusable']) {
408                return $persistedCrl['content'];
409            }
410
411            $waitedPersistedCrl = $this->waitForPersistedGeneratedCrl($instanceId, $generation, $engineType);
412            if ($waitedPersistedCrl !== null && ($waitedPersistedCrl['isFresh'] || (!$forceRefresh && $waitedPersistedCrl['isReusable']))) {
413                return $waitedPersistedCrl['content'];
414            }
415
416            $lockAcquired = $this->lockCache->add($lockKey, $lockToken, self::GENERATED_CRL_LOCK_TTL);
417            if (!$lockAcquired) {
418                throw new CrlGenerationInProgressException(sprintf(
419                    'CRL %s is already in progress for %s/%d/%s',
420                    $forceRefresh ? 'refresh' : 'generation',
421                    $instanceId,
422                    $generation,
423                    $engineType,
424                ));
425            }
426        }
427
428        try {
429            if (!$forceRefresh) {
430                $reloadedPersistedCrl = $this->readPersistedGeneratedCrl($instanceId, $generation, $engineType);
431                if ($reloadedPersistedCrl !== null && $reloadedPersistedCrl['isFresh']) {
432                    return $reloadedPersistedCrl['content'];
433                }
434            }
435
436            return $this->buildAndPersistGeneratedCrlDer($instanceId, $generation, $engineType);
437        } finally {
438            $this->lockCache->cad($lockKey, $lockToken);
439        }
440    }
441
442    /**
443     * @return array{content: string, isFresh: bool, isReusable: bool}|null
444     */
445    private function waitForPersistedGeneratedCrl(string $instanceId, int $generation, string $engineType): ?array {
446        for ($attempt = 0; $attempt < self::GENERATED_CRL_LOCK_WAIT_ATTEMPTS; $attempt++) {
447            usleep(self::GENERATED_CRL_LOCK_WAIT_USEC);
448            $persistedCrl = $this->readPersistedGeneratedCrl($instanceId, $generation, $engineType);
449            if ($persistedCrl !== null && ($persistedCrl['isFresh'] || $persistedCrl['isReusable'])) {
450                return $persistedCrl;
451            }
452        }
453
454        return null;
455    }
456
457    /**
458     * @return array{content: string, isFresh: bool, isReusable: bool}|null
459     */
460    private function readPersistedGeneratedCrl(string $instanceId, int $generation, string $engineType): ?array {
461        $content = $this->generatedCrlStorage->read($instanceId, $generation, $engineType);
462        if ($content === null) {
463            return null;
464        }
465
466        $generatedAt = $this->getPersistedGeneratedCrlTimestamp($instanceId, $generation, $engineType);
467        $isReusable = $generatedAt !== null
468            && $generatedAt >= (time() - self::GENERATED_CRL_MAX_STALE_AGE);
469
470        return [
471            'content' => $content,
472            'isFresh' => $this->isPersistedGeneratedCrlFresh($instanceId, $generation, $engineType),
473            'isReusable' => $isReusable,
474        ];
475    }
476
477    private function isPersistedGeneratedCrlFresh(string $instanceId, int $generation, string $engineType): bool {
478        $metadata = $this->generatedCrlStorage->readMetadata($instanceId, $generation, $engineType);
479        $refreshDate = $metadata['refreshDate'] ?? null;
480        if (is_string($refreshDate) && $refreshDate !== '') {
481            return $refreshDate === $this->getCurrentRefreshDate();
482        }
483
484        $generatedAt = $this->getPersistedGeneratedCrlTimestamp($instanceId, $generation, $engineType);
485        if ($generatedAt === null) {
486            return false;
487        }
488
489        return (new \DateTimeImmutable('@' . $generatedAt))
490            ->setTimezone(new \DateTimeZone(date_default_timezone_get()))
491            ->format(self::GENERATED_CRL_REFRESH_DATE_FORMAT) === $this->getCurrentRefreshDate();
492    }
493
494    private function getPersistedGeneratedCrlTimestamp(string $instanceId, int $generation, string $engineType): ?int {
495        $metadata = $this->generatedCrlStorage->readMetadata($instanceId, $generation, $engineType);
496        $generatedAt = $metadata['generatedAt'] ?? null;
497        if (is_string($generatedAt) && $generatedAt !== '') {
498            try {
499                return (new \DateTimeImmutable($generatedAt))->getTimestamp();
500            } catch (\Exception) {
501                // Fall back to file mtime below.
502            }
503        }
504
505        return $this->generatedCrlStorage->getMTime($instanceId, $generation, $engineType);
506    }
507
508    private function buildAndPersistGeneratedCrlDer(string $instanceId, int $generation, string $engineType): string {
509        try {
510            $crlDer = $this->buildCrlDer($instanceId, $generation, $engineType);
511            $this->generatedCrlStorage->write(
512                $instanceId,
513                $generation,
514                $engineType,
515                $crlDer,
516                [
517                    'refreshDate' => $this->getCurrentRefreshDate(),
518                    'generatedAt' => (new \DateTimeImmutable('now', new \DateTimeZone('UTC')))->format(\DateTimeInterface::ATOM),
519                    'engineType' => $this->normalizeEngineType($engineType)->value,
520                ]
521            );
522
523            return $crlDer;
524        } catch (\Throwable $e) {
525            if ($e instanceof \RuntimeException && str_starts_with($e->getMessage(), 'Config path does not exist for instanceId:')) {
526                $this->logger->debug('Skipping local CRL generation because source PKI config path is missing', [
527                    'instanceId' => $instanceId,
528                    'generation' => $generation,
529                    'engineType' => $engineType,
530                    'reason' => $e->getMessage(),
531                ]);
532            } else {
533                $this->logger->error('Failed to generate CRL', [
534                    'exception' => $e,
535                ]);
536            }
537            throw $e;
538        }
539    }
540
541    private function getCurrentRefreshDate(): string {
542        return (new \DateTimeImmutable('now', new \DateTimeZone(date_default_timezone_get())))
543            ->format(self::GENERATED_CRL_REFRESH_DATE_FORMAT);
544    }
545
546    private function buildCrlDer(string $instanceId, int $generation, string $engineType): string {
547        $revokedCertificates = $this->crlMapper->getRevokedCertificates($instanceId, $generation, $engineType);
548
549        $engine = $this->certificateEngineFactory->getEngine();
550
551        if (!method_exists($engine, 'generateCrlDer')) {
552            throw new \RuntimeException('Current certificate engine does not support CRL generation');
553        }
554
555        $crlNumber = $this->getNextCrlNumber($instanceId, $generation, $engineType);
556
557        return $engine->generateCrlDer($revokedCertificates, $instanceId, $generation, $crlNumber);
558    }
559
560    /**
561     * List CRL entries with pagination and filters
562     *
563     * @param int|null $page Page number (1-based), defaults to 1
564     * @param int|null $length Number of items per page, defaults to 100
565     * @param array<string, mixed> $filter Filters to apply (status, engine, instance_id, owner, serial_number, revoked_by, generation)
566     * @param array<string, string> $sort Sort fields and directions ['field' => 'ASC|DESC']
567     * @return LibresignCrlListResponse
568     */
569    public function listCrlEntries(
570        ?int $page = null,
571        ?int $length = null,
572        array $filter = [],
573        array $sort = [],
574    ): array {
575        $page ??= 1;
576        $length ??= 100;
577
578        $result = $this->crlMapper->listWithPagination($page, $length, $filter, $sort);
579
580        /** @var list<LibresignCrlListItem> $formattedData */
581        $formattedData = array_values(array_map(
582            fn (CrlEntity $entity): array => $this->formatCrlListEntry($entity),
583            $result['data']
584        ));
585
586        /** @var LibresignCrlListResponse $response */
587        $response = [
588            'data' => $formattedData,
589            'total' => $result['total'],
590            'page' => $page,
591            'length' => $length,
592        ];
593
594        return $response;
595    }
596
597    /**
598     * @return LibresignCrlListItem
599     */
600    private function formatCrlListEntry(CrlEntity $entity): array {
601        return [
602            'id' => (int)$entity->getId(),
603            'serial_number' => $entity->getSerialNumber(),
604            'owner' => $entity->getOwner(),
605            'status' => CRLStatus::from($entity->getStatus())->value,
606            'certificate_type' => $entity->getCertificateType(),
607            'engine' => $entity->getEngine(),
608            'instance_id' => $entity->getInstanceId(),
609            'generation' => $entity->getGeneration(),
610            'issued_at' => $entity->getIssuedAt()?->format('Y-m-d H:i:s'),
611            'valid_to' => $entity->getValidTo()?->format('Y-m-d H:i:s'),
612            'revoked_at' => $entity->getRevokedAt()?->format('Y-m-d H:i:s'),
613            'reason_code' => $entity->getReasonCode(),
614            'comment' => $entity->getComment(),
615            'revoked_by' => $entity->getRevokedBy(),
616            'invalidity_date' => $entity->getInvalidityDate()?->format('Y-m-d H:i:s'),
617            'crl_number' => $entity->getCrlNumber(),
618        ];
619    }
620}