Code Coverage
 
Lines
Functions and Methods
Classes and Traits
Total
24.20% covered (danger)
24.20%
91 / 376
6.90% covered (danger)
6.90%
2 / 29
CRAP
0.00% covered (danger)
0.00%
0 / 1
CfsslHandler
24.20% covered (danger)
24.20%
91 / 376
6.90% covered (danger)
6.90%
2 / 29
4814.18
0.00% covered (danger)
0.00%
0 / 1
 __construct
100.00% covered (success)
100.00%
14 / 14
100.00% covered (success)
100.00%
1 / 1
1
 generateRootCert
0.00% covered (danger)
0.00%
0 / 14
0.00% covered (danger)
0.00%
0 / 1
20
 generateCertificate
0.00% covered (danger)
0.00%
0 / 16
0.00% covered (danger)
0.00%
0 / 1
2
 isSetupOk
0.00% covered (danger)
0.00%
0 / 11
0.00% covered (danger)
0.00%
0 / 1
42
 getConfigureCheckResourceName
0.00% covered (danger)
0.00%
0 / 1
0.00% covered (danger)
0.00%
0 / 1
2
 getCertificateRegenerationTip
0.00% covered (danger)
0.00%
0 / 1
0.00% covered (danger)
0.00%
0 / 1
2
 getEngineSpecificChecks
0.00% covered (danger)
0.00%
0 / 1
0.00% covered (danger)
0.00%
0 / 1
2
 getSetupSuccessMessage
0.00% covered (danger)
0.00%
0 / 1
0.00% covered (danger)
0.00%
0 / 1
2
 getSetupErrorMessage
0.00% covered (danger)
0.00%
0 / 1
0.00% covered (danger)
0.00%
0 / 1
2
 getSetupErrorTip
0.00% covered (danger)
0.00%
0 / 1
0.00% covered (danger)
0.00%
0 / 1
2
 toArray
0.00% covered (danger)
0.00%
0 / 4
0.00% covered (danger)
0.00%
0 / 1
6
 getCommonName
0.00% covered (danger)
0.00%
0 / 4
0.00% covered (danger)
0.00%
0 / 1
6
 newCert
0.00% covered (danger)
0.00%
0 / 35
0.00% covered (danger)
0.00%
0 / 1
110
 gencert
0.00% covered (danger)
0.00%
0 / 15
0.00% covered (danger)
0.00%
0 / 1
30
 getClient
0.00% covered (danger)
0.00%
0 / 4
0.00% covered (danger)
0.00%
0 / 1
6
 isUp
0.00% covered (danger)
0.00%
0 / 26
0.00% covered (danger)
0.00%
0 / 1
110
 wakeUp
0.00% covered (danger)
0.00%
0 / 28
0.00% covered (danger)
0.00%
0 / 1
42
 portOpen
0.00% covered (danger)
0.00%
0 / 9
0.00% covered (danger)
0.00%
0 / 1
20
 getServerPid
100.00% covered (success)
100.00%
5 / 5
100.00% covered (success)
100.00%
1 / 1
1
 stopIfRunning
88.89% covered (warning)
88.89%
16 / 18
0.00% covered (danger)
0.00%
0 / 1
4.02
 getBinary
0.00% covered (danger)
0.00%
0 / 10
0.00% covered (danger)
0.00%
0 / 1
30
 getCfsslUri
71.43% covered (warning)
71.43%
5 / 7
0.00% covered (danger)
0.00%
0 / 1
3.21
 setCfsslUri
0.00% covered (danger)
0.00%
0 / 4
0.00% covered (danger)
0.00%
0 / 1
6
 checkBinaries
75.00% covered (warning)
75.00%
51 / 68
0.00% covered (danger)
0.00%
0 / 1
11.56
 createProcess
0.00% covered (danger)
0.00%
0 / 1
0.00% covered (danger)
0.00%
0 / 1
2
 getAuthorityKeyId
0.00% covered (danger)
0.00%
0 / 10
0.00% covered (danger)
0.00%
0 / 1
30
 revokeCertificate
0.00% covered (danger)
0.00%
0 / 19
0.00% covered (danger)
0.00%
0 / 1
30
 persistSerialNumberToCrl
0.00% covered (danger)
0.00%
0 / 21
0.00% covered (danger)
0.00%
0 / 1
20
 persistRootCertificateFromData
0.00% covered (danger)
0.00%
0 / 27
0.00% covered (danger)
0.00%
0 / 1
30
1<?php
2
3declare(strict_types=1);
4/**
5 * SPDX-FileCopyrightText: 2020-2024 LibreCode coop and contributors
6 * SPDX-License-Identifier: AGPL-3.0-or-later
7 */
8
9namespace OCA\Libresign\Handler\CertificateEngine;
10
11use GuzzleHttp\Client;
12use GuzzleHttp\Exception\ConnectException;
13use GuzzleHttp\Exception\RequestException;
14use OCA\Libresign\AppInfo\Application;
15use OCA\Libresign\Db\CrlMapper;
16use OCA\Libresign\Enum\CertificateType;
17use OCA\Libresign\Exception\EmptyCertificateException;
18use OCA\Libresign\Exception\LibresignException;
19use OCA\Libresign\Handler\CfsslServerHandler;
20use OCA\Libresign\Helper\ConfigureCheckHelper;
21use OCA\Libresign\Service\CaIdentifierService;
22use OCA\Libresign\Service\CertificatePolicyService;
23use OCA\Libresign\Service\Crl\CrlRevocationChecker;
24use OCA\Libresign\Service\Install\InstallService;
25use OCA\Libresign\Service\Policy\PolicyService;
26use OCA\Libresign\Service\Process\ProcessManager;
27use OCA\Libresign\Vendor\Symfony\Component\Process\Process;
28use OCP\Files\AppData\IAppDataFactory;
29use OCP\IAppConfig;
30use OCP\IConfig;
31use OCP\IDateTimeFormatter;
32use OCP\ITempManager;
33use OCP\IURLGenerator;
34use Psr\Log\LoggerInterface;
35
36/**
37 * Class CfsslHandler
38 *
39 * @package OCA\Libresign\Handler
40 *
41 * @method CfsslHandler setClient(Client $client)
42 */
43class CfsslHandler extends AEngineHandler implements IEngineHandler {
44    public const CFSSL_URI = 'http://127.0.0.1:8888/api/v1/cfssl/';
45    private const string PROCESS_SOURCE = 'cfssl';
46    private const STARTUP_POLL_INTERVAL_MICROSECONDS = 250000;
47    private const STARTUP_MAX_POLLS = 40;
48
49    /** @var Client */
50    protected $client;
51    protected $cfsslUri;
52    private string $binary = '';
53
54    public function __construct(
55        protected IConfig $config,
56        protected IAppConfig $appConfig,
57        protected IAppDataFactory $appDataFactory,
58        protected IDateTimeFormatter $dateTimeFormatter,
59        protected ITempManager $tempManager,
60        protected CfsslServerHandler $cfsslServerHandler,
61        protected CertificatePolicyService $certificatePolicyService,
62        protected IURLGenerator $urlGenerator,
63        protected CaIdentifierService $caIdentifierService,
64        protected PolicyService $policyService,
65        protected CrlMapper $crlMapper,
66        protected LoggerInterface $logger,
67        CrlRevocationChecker $crlRevocationChecker,
68        private ProcessManager $processManager,
69    ) {
70        parent::__construct(
71            $config,
72            $appConfig,
73            $appDataFactory,
74            $dateTimeFormatter,
75            $tempManager,
76            $certificatePolicyService,
77            $urlGenerator,
78            $caIdentifierService,
79            $policyService,
80            $logger,
81            $crlRevocationChecker,
82        );
83
84        $this->cfsslServerHandler->configCallback(fn () => $this->getCurrentConfigPath());
85    }
86
87    #[\Override]
88    public function generateRootCert(
89        string $commonName,
90        array $names = [],
91    ): void {
92        if (empty($commonName)) {
93            throw new EmptyCertificateException('Common Name (CN) cannot be empty for root certificate');
94        }
95
96        $this->cfsslServerHandler->createConfigServer(
97            $commonName,
98            $names,
99            $this->getCaExpiryInDays(),
100            $this->getCrlDistributionUrl(),
101        );
102
103        $this->gencert();
104
105        $this->stopIfRunning();
106
107        for ($i = 1; $i <= 4; $i++) {
108            if ($this->isUp()) {
109                break;
110            }
111            sleep(2);
112        }
113    }
114
115    #[\Override]
116    public function generateCertificate(): string {
117        $this->validateRootCertificate();
118
119        $certKeys = $this->newCert();
120        $pkcs12 = parent::exportToPkcs12(
121            $certKeys['certificate'],
122            $certKeys['private_key'],
123            [
124                'friendly_name' => $this->getFriendlyName(),
125                'extracerts' => [
126                    $certKeys['certificate'],
127                    $certKeys['certificate_request'],
128                ],
129            ],
130        );
131
132        $parsed = $this->readCertificate($pkcs12, $this->getPassword());
133        $this->persistSerialNumberToCrl($parsed);
134
135        return $pkcs12;
136    }
137
138    #[\Override]
139    public function isSetupOk(): bool {
140        $configPath = $this->getCurrentConfigPath();
141        $certificate = file_exists($configPath . DIRECTORY_SEPARATOR . 'ca.pem');
142        $privateKey = file_exists($configPath . DIRECTORY_SEPARATOR . 'ca-key.pem');
143        $csrServer = file_exists($configPath . DIRECTORY_SEPARATOR . 'csr_server.json');
144        $configServer = file_exists($configPath . DIRECTORY_SEPARATOR . 'config_server.json');
145        if (!$certificate || !$privateKey || !$csrServer || !$configServer) {
146            return false;
147        }
148        try {
149            $this->getClient();
150            return true;
151        } catch (\Throwable) {
152        }
153        return false;
154    }
155
156    #[\Override]
157    protected function getConfigureCheckResourceName(): string {
158        return 'cfssl-configure';
159    }
160
161    #[\Override]
162    protected function getCertificateRegenerationTip(): string {
163        return 'Consider regenerating the root certificate with: occ libresign:configure:cfssl --cn="Your CA Name"';
164    }
165
166    #[\Override]
167    protected function getEngineSpecificChecks(): array {
168        return $this->checkBinaries();
169    }
170
171    #[\Override]
172    protected function getSetupSuccessMessage(): string {
173        return 'Root certificate config files found.';
174    }
175
176    #[\Override]
177    protected function getSetupErrorMessage(): string {
178        return 'CFSSL (root certificate) not configured.';
179    }
180
181    #[\Override]
182    protected function getSetupErrorTip(): string {
183        return 'Run occ libresign:configure:cfssl --help';
184    }
185
186    #[\Override]
187    public function toArray(): array {
188        $return = parent::toArray();
189        if (!empty($return['configPath'])) {
190            $return['cfsslUri'] = $this->appConfig->getValueString(Application::APP_ID, 'cfssl_uri');
191        }
192        return $return;
193    }
194
195    public function getCommonName(): string {
196        $uid = $this->getUID();
197        if (!$uid) {
198            return $this->commonName;
199        }
200        return $uid . ', ' . $this->commonName;
201    }
202
203    private function newCert(): array {
204        $json = [
205            'json' => [
206                'profile' => 'client',
207                'request' => [
208                    'hosts' => $this->getHosts(),
209                    'CN' => $this->getCommonName(),
210                    'key' => [
211                        'algo' => 'rsa',
212                        'size' => 2048,
213                    ],
214                    'names' => [],
215                    'crl_url' => $this->getCrlDistributionUrl(),
216                ],
217            ],
218        ];
219
220        $names = $this->getNames();
221        foreach ($names as $key => $value) {
222            if (!empty($value) && is_array($value)) {
223                $names[$key] = implode(', ', $value);
224            }
225        }
226        if (!empty($names)) {
227            $json['json']['request']['names'][] = $names;
228        }
229
230        try {
231            $response = $this->getClient()
232                ->request('post',
233                    'newcert',
234                    $json
235                )
236            ;
237        } catch (RequestException|ConnectException $th) {
238            if ($th->getHandlerContext() && $th->getHandlerContext()['error']) {
239                throw new \Exception($th->getHandlerContext()['error'], 1);
240            }
241            throw new LibresignException($th->getMessage(), 500);
242        }
243
244        $responseDecoded = json_decode((string)$response->getBody(), true);
245        if (!isset($responseDecoded['success']) || !$responseDecoded['success']) {
246            throw new LibresignException('Error while generating certificate keys!', 500);
247        }
248
249        return $responseDecoded['result'];
250    }
251
252    private function gencert(): void {
253        $binary = $this->getBinary();
254        $configPath = $this->getCurrentConfigPath();
255        $csrFile = $configPath . '/csr_server.json';
256
257        $process = $this->createProcess([$binary, 'gencert', '-initca', $csrFile]);
258        $process->run();
259        $output = $process->getOutput();
260
261        if (!$process->isSuccessful() || $output === '') {
262            throw new \RuntimeException('cfssl without output.');
263        }
264
265        $json = json_decode($output, true);
266        if (!$json || !isset($json['cert'], $json['key'], $json['csr'])) {
267            throw new \RuntimeException('Error generating CA: invalid cfssl output.');
268        }
269
270        file_put_contents($configPath . '/ca.pem', $json['cert']);
271        file_put_contents($configPath . '/ca-key.pem', $json['key']);
272        file_put_contents($configPath . '/ca.csr', $json['csr']);
273
274        $this->persistRootCertificateFromData($json['cert']);
275    }
276
277    private function getClient(): Client {
278        if (!$this->client) {
279            $this->setClient(new Client(['base_uri' => $this->getCfsslUri()]));
280        }
281        $this->wakeUp();
282        return $this->client;
283    }
284
285    private function isUp(): bool {
286        try {
287            $client = $this->getClient();
288            if (!$this->portOpen()) {
289                return false;
290            }
291            $response = $client
292                ->request('get',
293                    'health',
294                    [
295                        'base_uri' => $this->getCfsslUri()
296                    ]
297                )
298            ;
299        } catch (ConnectException) {
300            // Port not yet accepting connections â€” server still starting
301            return false;
302            return false;
303        } catch (RequestException $th) {
304            switch ($th->getCode()) {
305                case 404:
306                    throw new \Exception('Endpoint /health of CFSSL server not found. Maybe you are using incompatible version of CFSSL server. Use latests version.', 1);
307                default:
308                    if ($th->getHandlerContext() && $th->getHandlerContext()['error']) {
309                        if (str_contains((string)$th->getHandlerContext()['error'], 'connect')) {
310                            return false;
311                        }
312                        throw new \Exception($th->getHandlerContext()['error'], 1);
313                    }
314                    return false;
315            }
316            return false;
317        }
318
319        $responseDecoded = json_decode((string)$response->getBody(), true);
320        if (empty($responseDecoded['result']['healthy'])) {
321            return false;
322        }
323
324        return (bool)$responseDecoded['result']['healthy'];
325    }
326
327    private function wakeUp(): void {
328        if ($this->portOpen()) {
329            return;
330        }
331
332        $binary = $this->getBinary();
333        $configPath = $this->getCurrentConfigPath();
334        if (!$configPath) {
335            throw new LibresignException('CFSSL not configured.');
336        }
337        $this->cfsslServerHandler->updateExpirity($this->getCaExpiryInDays());
338        $process = $this->createProcess([
339            $binary,
340            'serve',
341            '-address=127.0.0.1',
342            '-ca-key', $configPath . DIRECTORY_SEPARATOR . 'ca-key.pem',
343            '-ca', $configPath . DIRECTORY_SEPARATOR . 'ca.pem',
344            '-config', $configPath . DIRECTORY_SEPARATOR . 'config_server.json',
345        ]);
346        $process->setOptions(['create_new_console' => true]);
347        $process->setTimeout(null);
348        $process->disableOutput();
349        $process->start();
350
351        $pid = (int)($process->getPid() ?? 0);
352        if ($pid > 0) {
353            $this->processManager->register(self::PROCESS_SOURCE, $pid, [
354                'uri' => $this->getCfsslUri(),
355            ]);
356        }
357
358        $polls = 0;
359        while (!$this->portOpen() && $polls < self::STARTUP_MAX_POLLS) {
360            usleep(self::STARTUP_POLL_INTERVAL_MICROSECONDS);
361            $polls++;
362        }
363    }
364
365    private function portOpen(): bool {
366        $host = parse_url($this->getCfsslUri(), PHP_URL_HOST);
367        $port = parse_url($this->getCfsslUri(), PHP_URL_PORT);
368
369        set_error_handler(fn (int $errno, string $errstr, string $errfile = '', int $errline = 0, array $errcontext = []): bool => true);
370        $socket = fsockopen($host, $port, $errno, $errstr, 0.1);
371        restore_error_handler();
372        if (!$socket || $errno || $errstr) {
373            return false;
374        }
375        fclose($socket);
376        return true;
377    }
378
379    private function getServerPid(): int {
380        $uri = $this->getCfsslUri();
381        return $this->processManager->findRunningPid(
382            self::PROCESS_SOURCE,
383            fn (array $entry): bool => ($entry['context']['uri'] ?? '') === $uri,
384        );
385    }
386
387    private function stopIfRunning(): void {
388        $uri = $this->getCfsslUri();
389        $port = (int)(parse_url($uri, PHP_URL_PORT) ?? 0);
390        $this->processManager->setSourceHint(self::PROCESS_SOURCE, [
391            'uri' => $uri,
392            'port' => $port,
393        ]);
394
395        $this->processManager->findRunningPid(
396            self::PROCESS_SOURCE,
397            fn (array $entry): bool => ($entry['context']['uri'] ?? '') === $uri,
398        );
399
400        foreach ($this->processManager->listRunning(self::PROCESS_SOURCE) as $entry) {
401            if (($entry['context']['uri'] ?? '') !== $uri) {
402                continue;
403            }
404
405            $pid = (int)($entry['pid'] ?? 0);
406            if ($pid <= 0) {
407                continue;
408            }
409            $this->processManager->stopPid($pid, SIGKILL);
410            $this->processManager->unregister(self::PROCESS_SOURCE, $pid);
411        }
412    }
413
414    private function getBinary(): string {
415        if ($this->binary) {
416            return $this->binary;
417        }
418
419        if (PHP_OS_FAMILY === 'Windows') {
420            throw new LibresignException('Incompatible with Windows');
421        }
422
423        if ($this->appConfig->hasKey(Application::APP_ID, 'cfssl_bin')) {
424            $binary = $this->appConfig->getValueString(Application::APP_ID, 'cfssl_bin');
425            if (!file_exists($binary)) {
426                $this->appConfig->deleteKey(Application::APP_ID, 'cfssl_bin');
427            }
428            return $binary;
429        }
430        throw new LibresignException('Binary of CFSSL not found. Install binaries.');
431    }
432
433    private function getCfsslUri(): string {
434        if ($this->cfsslUri) {
435            return $this->cfsslUri;
436        }
437
438        if ($uri = $this->appConfig->getValueString(Application::APP_ID, 'cfssl_uri')) {
439            return $uri;
440        }
441        $this->appConfig->deleteKey(Application::APP_ID, 'cfssl_uri');
442
443        $this->cfsslUri = self::CFSSL_URI;
444        return $this->cfsslUri;
445    }
446
447    public function setCfsslUri($uri): void {
448        if ($uri) {
449            $this->appConfig->setValueString(Application::APP_ID, 'cfssl_uri', $uri);
450        } else {
451            $this->appConfig->deleteKey(Application::APP_ID, 'cfssl_uri');
452        }
453        $this->cfsslUri = $uri;
454    }
455
456    private function checkBinaries(): array {
457        if (PHP_OS_FAMILY === 'Windows') {
458            return [
459                (new ConfigureCheckHelper())
460                    ->setErrorMessage('CFSSL is incompatible with Windows')
461                    ->setResource('cfssl'),
462            ];
463        }
464        $binary = $this->appConfig->getValueString(Application::APP_ID, 'cfssl_bin');
465        if (!$binary) {
466            return [
467                (new ConfigureCheckHelper())
468                    ->setErrorMessage('CFSSL not installed.')
469                    ->setResource('cfssl')
470                    ->setTip('Run occ libresign:install --cfssl'),
471            ];
472        }
473
474        if (!file_exists($binary)) {
475            return [
476                (new ConfigureCheckHelper())
477                    ->setErrorMessage('CFSSL not found.')
478                    ->setResource('cfssl')
479                    ->setTip('Run occ libresign:install --cfssl'),
480            ];
481        }
482        $process = $this->createProcess([$binary, 'version']);
483        $process->run();
484        $version = $process->getOutput();
485        if (!$process->isSuccessful() || empty($version)) {
486            return [
487                (new ConfigureCheckHelper())
488                    ->setErrorMessage(sprintf(
489                        'Failed to run the command "%s" with user %s',
490                        "$binary version",
491                        get_current_user()
492                    ))
493                    ->setResource('cfssl')
494                    ->setTip('Run occ libresign:install --cfssl')
495            ];
496        }
497        preg_match_all('/: (?<version>.*)/', $version, $matches);
498        if (!$matches || !isset($matches['version']) || count($matches['version']) !== 2) {
499            return [
500                (new ConfigureCheckHelper())
501                    ->setErrorMessage(sprintf(
502                        'Failed to identify cfssl version with command %s',
503                        "$binary version"
504                    ))
505                    ->setResource('cfssl')
506                    ->setTip('Run occ libresign:install --cfssl')
507            ];
508        }
509        if (!str_contains($matches['version'][0], InstallService::CFSSL_VERSION)) {
510            return [
511                (new ConfigureCheckHelper())
512                    ->setErrorMessage(sprintf(
513                        'Invalid version. Expected: %s, actual: %s',
514                        InstallService::CFSSL_VERSION,
515                        $matches['version'][0]
516                    ))
517                    ->setResource('cfssl')
518                    ->setTip('Run occ libresign:install --cfssl')
519            ];
520        }
521        $return = [];
522        $return[] = (new ConfigureCheckHelper())
523            ->setSuccessMessage('CFSSL binary path: ' . $binary)
524            ->setResource('cfssl');
525        $return[] = (new ConfigureCheckHelper())
526            ->setSuccessMessage('CFSSL version: ' . $matches['version'][0])
527            ->setResource('cfssl');
528        $return[] = (new ConfigureCheckHelper())
529            ->setSuccessMessage('Runtime: ' . $matches['version'][1])
530            ->setResource('cfssl');
531        return $return;
532    }
533
534    /**
535     * @param string[] $command
536     */
537    protected function createProcess(array $command): Process {
538        return new Process($command);
539    }
540
541    /**
542     * Get Authority Key Identifier from certificate (needed for CFSSL revocation)
543     *
544     * @param string $certificatePem PEM encoded certificate
545     * @return string Authority Key Identifier in lowercase without colons
546     */
547    public function getAuthorityKeyId(string $certificatePem): string {
548        $cert = openssl_x509_read($certificatePem);
549        if (!$cert) {
550            throw new \RuntimeException('Invalid certificate format');
551        }
552
553        $parsed = openssl_x509_parse($cert);
554        if (!$parsed || !isset($parsed['extensions']['authorityKeyIdentifier'])) {
555            throw new \RuntimeException('Certificate does not contain Authority Key Identifier');
556        }
557
558        $authKeyId = $parsed['extensions']['authorityKeyIdentifier'];
559
560        if (preg_match('/keyid:([A-Fa-f0-9:]+)/', $authKeyId, $matches)) {
561            return strtolower(str_replace(':', '', $matches[1]));
562        }
563
564        throw new \RuntimeException('Could not parse Authority Key Identifier');
565    }
566
567    /**
568     * Revoke a certificate using CFSSL API
569     *
570     * @param string $serialNumber Certificate serial number in decimal format
571     * @param string $authorityKeyId Authority key identifier (lowercase, no colons)
572     * @param string $reason CRLReason description string (e.g., 'superseded', 'keyCompromise')
573     */
574    public function revokeCertificate(string $serialNumber, string $authorityKeyId, string $reason): bool {
575        try {
576            $json = [
577                'json' => [
578                    'serial' => $serialNumber,
579                    'authority_key_id' => $authorityKeyId,
580                    'reason' => $reason,
581                ],
582            ];
583
584            $response = $this->getClient()->request('POST', 'revoke', $json);
585
586            $responseData = json_decode((string)$response->getBody(), true);
587
588            if (!isset($responseData['success'])) {
589                $errorMessage = isset($responseData['errors'])
590                    ? implode(', ', array_column($responseData['errors'], 'message'))
591                    : 'Unknown CFSSL error';
592                throw new \RuntimeException('CFSSL revocation failed: ' . $errorMessage);
593            }
594
595            return $responseData['success'];
596        } catch (RequestException|ConnectException $e) {
597            throw new \RuntimeException('Failed to communicate with CFSSL server: ' . $e->getMessage());
598        } catch (\Throwable $e) {
599            throw new \RuntimeException('CFSSL certificate revocation error: ' . $e->getMessage());
600        }
601    }
602
603    private function persistSerialNumberToCrl(array $parsed): void {
604        if (!isset($parsed['serialNumberHex']) || !isset($parsed['valid_to'])) {
605            return;
606        }
607
608        $serialNumber = $parsed['serialNumberHex'];
609
610        $owner = $this->getCommonName() ?? 'Unknown';
611
612        $expiresAt = null;
613        if (isset($parsed['validTo_time_t'])) {
614            $expiresAt = new \DateTime('@' . $parsed['validTo_time_t']);
615        }
616
617        $issuer = $parsed['issuer'] ?? [];
618        $subject = $parsed['subject'] ?? [];
619
620        $this->crlMapper->createCertificate(
621            $serialNumber,
622            $owner,
623            'cfssl',
624            $this->caIdentifierService->getInstanceId(),
625            $this->caIdentifierService->getCaIdParsed()['generation'],
626            new \DateTime(),
627            $expiresAt,
628            $issuer,
629            $subject,
630            CertificateType::LEAF->value,
631        );
632    }
633
634    private function persistRootCertificateFromData(string $certPem): void {
635        $x509Resource = openssl_x509_read($certPem);
636        if (!$x509Resource) {
637            throw new \RuntimeException('Failed to parse root certificate');
638        }
639
640        $parsed = openssl_x509_parse($x509Resource);
641        if (!$parsed) {
642            throw new \RuntimeException('Failed to extract root certificate information');
643        }
644
645        $serialNumber = $parsed['serialNumberHex'] ?? '';
646        if (empty($serialNumber)) {
647            throw new \RuntimeException('Root certificate has no serial number');
648        }
649
650        $owner = $this->getCommonName() ?? 'Root CA';
651
652        $expiresAt = null;
653        if (isset($parsed['validTo_time_t'])) {
654            $expiresAt = new \DateTime('@' . $parsed['validTo_time_t']);
655        }
656
657        /** @var array<string, mixed> $issuer */
658        $issuer = $parsed['issuer'] ?? [];
659        /** @var array<string, mixed> $subject */
660        $subject = $parsed['subject'] ?? [];
661
662        $this->crlMapper->createCertificate(
663            $serialNumber,
664            $owner,
665            'cfssl',
666            $this->caIdentifierService->getInstanceId(),
667            $this->caIdentifierService->getCaIdParsed()['generation'],
668            new \DateTime(),
669            $expiresAt,
670            $issuer,
671            $subject,
672            CertificateType::ROOT->value,
673        );
674    }
675}