Code Coverage
 
Lines
Functions and Methods
Classes and Traits
Total
66.47% covered (warning)
66.47%
335 / 504
42.55% covered (danger)
42.55%
20 / 47
CRAP
0.00% covered (danger)
0.00%
0 / 1
AEngineHandler
66.47% covered (warning)
66.47%
335 / 504
42.55% covered (danger)
42.55%
20 / 47
1245.82
0.00% covered (danger)
0.00%
0 / 1
 __construct
100.00% covered (success)
100.00%
2 / 2
100.00% covered (success)
100.00%
1 / 1
1
 exportToPkcs12
80.00% covered (warning)
80.00%
12 / 15
0.00% covered (danger)
0.00%
0 / 1
5.20
 updatePassword
0.00% covered (danger)
0.00%
0 / 6
0.00% covered (danger)
0.00%
0 / 1
20
 readCertificate
100.00% covered (success)
100.00%
9 / 9
100.00% covered (success)
100.00%
1 / 1
5
 getCaId
100.00% covered (success)
100.00%
5 / 5
100.00% covered (success)
100.00%
1 / 1
3
 parseCertificate
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 setPolicyUserIdForValidation
75.00% covered (warning)
75.00%
3 / 4
0.00% covered (danger)
0.00%
0 / 1
3.14
 parseX509
100.00% covered (success)
100.00%
11 / 11
100.00% covered (success)
100.00%
1 / 1
5
 addCrlValidationInfo
77.78% covered (warning)
77.78%
14 / 18
0.00% covered (danger)
0.00%
0 / 1
6.40
 convertArrayToUtf8
100.00% covered (success)
100.00%
6 / 6
100.00% covered (success)
100.00%
1 / 1
4
 opensslPkcs12Read
27.27% covered (danger)
27.27%
6 / 22
0.00% covered (danger)
0.00%
0 / 1
10.15
 translateToLong
60.00% covered (warning)
60.00%
6 / 10
0.00% covered (danger)
0.00%
0 / 1
1.06
 setEngine
100.00% covered (success)
100.00%
3 / 3
100.00% covered (success)
100.00%
1 / 1
1
 configureIdentifyMethodsForEngine
100.00% covered (success)
100.00%
8 / 8
100.00% covered (success)
100.00%
1 / 1
2
 getEngine
16.67% covered (danger)
16.67%
2 / 12
0.00% covered (danger)
0.00%
0 / 1
26.83
 populateInstance
30.77% covered (danger)
30.77%
4 / 13
0.00% covered (danger)
0.00%
0 / 1
23.26
 getCurrentConfigPath
100.00% covered (success)
100.00%
10 / 10
100.00% covered (success)
100.00%
1 / 1
5
 getConfigPathByParams
87.50% covered (warning)
87.50%
7 / 8
0.00% covered (danger)
0.00%
0 / 1
2.01
 initializePkiConfigPath
90.00% covered (success)
90.00%
9 / 10
0.00% covered (danger)
0.00%
0 / 1
3.01
 createDirectoryWithCorrectOwnership
80.00% covered (warning)
80.00%
4 / 5
0.00% covered (danger)
0.00%
0 / 1
2.03
 getFilesOwnerInfo
75.00% covered (warning)
75.00%
6 / 8
0.00% covered (danger)
0.00%
0 / 1
3.14
 getInternalPathOfFolder
0.00% covered (danger)
0.00%
0 / 5
0.00% covered (danger)
0.00%
0 / 1
2
 setConfigPath
50.00% covered (danger)
50.00%
5 / 10
0.00% covered (danger)
0.00%
0 / 1
4.12
 getName
100.00% covered (success)
100.00%
4 / 4
100.00% covered (success)
100.00%
1 / 1
1
 getNames
90.91% covered (success)
90.91%
10 / 11
0.00% covered (danger)
0.00%
0 / 1
2.00
 getUID
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 getLeafExpiryInDays
75.00% covered (warning)
75.00%
3 / 4
0.00% covered (danger)
0.00%
0 / 1
3.14
 setLeafExpiryOverrideInDays
0.00% covered (danger)
0.00%
0 / 2
0.00% covered (danger)
0.00%
0 / 1
12
 getCaExpiryInDays
100.00% covered (success)
100.00%
2 / 2
100.00% covered (success)
100.00%
1 / 1
2
 getCertificatePolicy
55.56% covered (warning)
55.56%
5 / 9
0.00% covered (danger)
0.00%
0 / 1
3.79
 getConfigureCheckResourceName
n/a
0 / 0
n/a
0 / 0
0
 getCertificateRegenerationTip
n/a
0 / 0
n/a
0 / 0
0
 getEngineSpecificChecks
n/a
0 / 0
n/a
0 / 0
0
 getSetupSuccessMessage
n/a
0 / 0
n/a
0 / 0
0
 getSetupErrorMessage
n/a
0 / 0
n/a
0 / 0
0
 getSetupErrorTip
n/a
0 / 0
n/a
0 / 0
0
 configureCheck
66.67% covered (warning)
66.67%
12 / 18
0.00% covered (danger)
0.00%
0 / 1
4.59
 checkRootCertificateModernFeatures
50.82% covered (warning)
50.82%
31 / 61
0.00% covered (danger)
0.00%
0 / 1
51.38
 validateLibresignCaUuidInCertificate
13.33% covered (danger)
13.33%
2 / 15
0.00% covered (danger)
0.00%
0 / 1
38.90
 getLibreSignInstanceId
0.00% covered (danger)
0.00%
0 / 4
0.00% covered (danger)
0.00%
0 / 1
6
 calculateRemainingDays
100.00% covered (success)
100.00%
3 / 3
100.00% covered (success)
100.00%
1 / 1
1
 checkRootCertificateExpiry
77.78% covered (warning)
77.78%
28 / 36
0.00% covered (danger)
0.00%
0 / 1
9.89
 toArray
100.00% covered (success)
100.00%
23 / 23
100.00% covered (success)
100.00%
1 / 1
3
 getConfigPathForApi
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
2
 filterNameValue
100.00% covered (success)
100.00%
5 / 5
100.00% covered (success)
100.00%
1 / 1
5
 removeCaIdFromOrganizationalUnit
100.00% covered (success)
100.00%
5 / 5
100.00% covered (success)
100.00%
1 / 1
1
 getCrlDistributionUrl
100.00% covered (success)
100.00%
6 / 6
100.00% covered (success)
100.00%
1 / 1
1
 generateCrlDer
100.00% covered (success)
100.00%
3 / 3
100.00% covered (success)
100.00%
1 / 1
1
 loadCaIssuer
62.50% covered (warning)
62.50%
10 / 16
0.00% covered (danger)
0.00%
0 / 1
7.90
 createAndSignCrl
94.44% covered (success)
94.44%
17 / 18
0.00% covered (danger)
0.00%
0 / 1
3.00
 createRevokedCertificateSerial
100.00% covered (success)
100.00%
7 / 7
100.00% covered (success)
100.00%
1 / 1
3
 validateRootCertificate
51.22% covered (warning)
51.22%
21 / 41
0.00% covered (danger)
0.00%
0 / 1
21.61
 checkCertificateRevoked
37.50% covered (danger)
37.50%
3 / 8
0.00% covered (danger)
0.00%
0 / 1
2.98
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 OCA\Libresign\AppInfo\Application;
12use OCA\Libresign\Enum\CrlValidationStatus;
13use OCA\Libresign\Exception\EmptyCertificateException;
14use OCA\Libresign\Exception\InvalidPasswordException;
15use OCA\Libresign\Exception\LibresignException;
16use OCA\Libresign\Helper\ConfigureCheckHelper;
17use OCA\Libresign\Helper\MagicGetterSetterTrait;
18use OCA\Libresign\Service\CaIdentifierService;
19use OCA\Libresign\Service\CertificatePolicyService;
20use OCA\Libresign\Service\Crl\CrlDistributionPointsExtractor;
21use OCA\Libresign\Service\Crl\CrlRevocationChecker;
22use OCA\Libresign\Service\Policy\PolicyService;
23use OCA\Libresign\Service\Policy\Provider\ExpirationRules\ExpirationRulesPolicy;
24use OCA\Libresign\Service\Policy\Provider\IdentifyMethods\IdentifyMethodsPolicy;
25use OCP\Files\AppData\IAppDataFactory;
26use OCP\Files\IAppData;
27use OCP\Files\SimpleFS\ISimpleFolder;
28use OCP\IAppConfig;
29use OCP\IConfig;
30use OCP\IDateTimeFormatter;
31use OCP\ITempManager;
32use OCP\IURLGenerator;
33use OpenSSLAsymmetricKey;
34use OpenSSLCertificate;
35use Psr\Log\LoggerInterface;
36use ReflectionClass;
37
38/**
39 * @method IEngineHandler setPassword(string $password)
40 * @method string getPassword()
41 * @method IEngineHandler setCommonName(string $commonName)
42 * @method string getCommonName()
43 * @method IEngineHandler setHosts(array $hosts)
44 * @method array getHosts()
45 * @method IEngineHandler setFriendlyName(string $friendlyName)
46 * @method string getFriendlyName()
47 * @method IEngineHandler setCountry(string $country)
48 * @method string getCountry()
49 * @method IEngineHandler setState(string $state)
50 * @method string getState()
51 * @method IEngineHandler setLocality(string $locality)
52 * @method string getLocality()
53 * @method IEngineHandler setOrganization(string $organization)
54 * @method string getOrganization()
55 * @method IEngineHandler setOrganizationalUnit(array $organizationalUnit)
56 * @method array getOrganizationalUnit()
57 * @method IEngineHandler setUID(string $UID)
58 * @method string getName()
59 */
60abstract class AEngineHandler implements IEngineHandler {
61    use MagicGetterSetterTrait;
62    use OrderCertificatesTrait;
63
64    protected string $commonName = '';
65    protected array $hosts = [];
66    protected string $friendlyName = '';
67    protected string $country = '';
68    protected string $state = '';
69    protected string $locality = '';
70    protected string $organization = '';
71    protected array $organizationalUnit = [];
72    protected string $UID = '';
73    private ?int $leafExpiryOverrideInDays = null;
74    protected string $password = '';
75    protected string $configPath = '';
76    protected string $engine = '';
77    protected string $certificate = '';
78    protected string $currentCaId = '';
79    protected IAppData $appData;
80    private CrlDistributionPointsExtractor $crlDistributionPointsExtractor;
81    private ?string $policyUserIdForValidation = null;
82
83    public function __construct(
84        protected IConfig $config,
85        protected IAppConfig $appConfig,
86        protected IAppDataFactory $appDataFactory,
87        protected IDateTimeFormatter $dateTimeFormatter,
88        protected ITempManager $tempManager,
89        protected CertificatePolicyService $certificatePolicyService,
90        protected IURLGenerator $urlGenerator,
91        protected CaIdentifierService $caIdentifierService,
92        protected PolicyService $policyService,
93        protected LoggerInterface $logger,
94        private CrlRevocationChecker $crlRevocationChecker,
95    ) {
96        $this->appData = $appDataFactory->get('libresign');
97        $this->crlDistributionPointsExtractor = new CrlDistributionPointsExtractor();
98    }
99
100    protected function exportToPkcs12(
101        OpenSSLCertificate|string $certificate,
102        OpenSSLAsymmetricKey|OpenSSLCertificate|string $privateKey,
103        array $options = [],
104    ): string {
105        if (empty($certificate) || empty($privateKey)) {
106            throw new EmptyCertificateException();
107        }
108        $certContent = null;
109        try {
110            openssl_pkcs12_export(
111                $certificate,
112                $certContent,
113                $privateKey,
114                $this->getPassword(),
115                $options,
116            );
117            if (!$certContent) {
118                throw new \Exception();
119            }
120        } catch (\Throwable) {
121            throw new LibresignException('Error while creating certificate file', 500);
122        }
123
124        return $certContent;
125    }
126
127    #[\Override]
128    public function updatePassword(string $certificate, string $currentPrivateKey, string $newPrivateKey): string {
129        if (empty($certificate) || empty($currentPrivateKey) || empty($newPrivateKey)) {
130            throw new EmptyCertificateException();
131        }
132        $certContent = $this->opensslPkcs12Read($certificate, $currentPrivateKey);
133        $this->setPassword($newPrivateKey);
134        $certContent = self::exportToPkcs12($certContent['cert'], $certContent['pkey']);
135        return $certContent;
136    }
137
138    #[\Override]
139    public function readCertificate(string $certificate, string $privateKey): array {
140        if (empty($certificate) || empty($privateKey)) {
141            throw new EmptyCertificateException();
142        }
143        $certContent = $this->opensslPkcs12Read($certificate, $privateKey);
144
145        $return = $this->parseX509($certContent['cert']);
146        if (isset($certContent['extracerts'])) {
147            foreach ($certContent['extracerts'] as $extraCert) {
148                $return['extracerts'][] = $this->parseX509($extraCert);
149            }
150            $return['extracerts'] = $this->orderCertificates($return['extracerts']);
151        }
152        return $return;
153    }
154
155    public function getCaId(): string {
156        $caId = $this->caIdentifierService->getCaId();
157        if (empty($caId)) {
158            $this->appConfig->clearCache(true);
159            $caId = $this->caIdentifierService->getCaId() ?: $this->caIdentifierService->generateCaId($this->getName());
160        }
161        return $caId;
162    }
163
164    #[\Override]
165    public function parseCertificate(string $certificate): array {
166        return $this->parseX509($certificate);
167    }
168
169    #[\Override]
170    public function setPolicyUserIdForValidation(?string $userId): self {
171        $this->policyUserIdForValidation = is_string($userId) && trim($userId) !== ''
172            ? trim($userId)
173            : null;
174
175        return $this;
176    }
177
178    private function parseX509(string $x509): array {
179        $parsed = openssl_x509_parse(openssl_x509_read($x509));
180
181        $return = self::convertArrayToUtf8($parsed);
182
183        foreach (['subject', 'issuer'] as $actor) {
184            foreach ($return[$actor] as $part => $value) {
185                if (is_string($value) && str_contains($value, ', ')) {
186                    $return[$actor][$part] = explode(', ', $value);
187                } else {
188                    $return[$actor][$part] = $value;
189                }
190            }
191        }
192
193        $return['valid_from'] = $this->dateTimeFormatter->formatDateTime($parsed['validFrom_time_t']);
194        $return['valid_to'] = $this->dateTimeFormatter->formatDateTime($parsed['validTo_time_t']);
195
196        $this->addCrlValidationInfo($return, $x509);
197
198        return $return;
199    }
200
201    private function addCrlValidationInfo(array &$certData, string $certPem): void {
202        $extensions = $certData['extensions'] ?? [];
203        if (is_array($extensions)) {
204            ['hasExtension' => $hasCrlExtension, 'urls' => $extractedUrls] = $this->crlDistributionPointsExtractor->extractFromExtensions($extensions);
205            if ($hasCrlExtension) {
206                $certData['crl_urls'] = $extractedUrls;
207                if (empty($extractedUrls)) {
208                    $certData['crl_validation'] = CrlValidationStatus::NO_URLS;
209                    return;
210                }
211
212                $crlDetails = $this->crlRevocationChecker->validate($extractedUrls, $certPem, $this->policyUserIdForValidation);
213                $certData['crl_validation'] = $crlDetails['status'];
214                if (!empty($crlDetails['revoked_at'])) {
215                    $certData['crl_revoked_at'] = $crlDetails['revoked_at'];
216                }
217                return;
218            }
219        }
220
221        $emptyCrlValidation = $this->crlRevocationChecker->validate([], $certPem, $this->policyUserIdForValidation);
222        $certData['crl_validation'] = ($emptyCrlValidation['status'] ?? CrlValidationStatus::NO_URLS) === CrlValidationStatus::DISABLED
223            ? CrlValidationStatus::DISABLED
224            : CrlValidationStatus::MISSING;
225        $certData['crl_urls'] = [];
226    }
227
228    private static function convertArrayToUtf8($array) {
229        foreach ($array as $key => $value) {
230            if (is_array($value)) {
231                $array[$key] = self::convertArrayToUtf8($value);
232            } elseif (is_string($value)) {
233                $array[$key] = mb_convert_encoding($value, 'UTF-8', 'UTF-8');
234            }
235        }
236        return $array;
237    }
238
239    public function opensslPkcs12Read(string &$certificate, string $privateKey): array {
240        openssl_pkcs12_read($certificate, $certContent, $privateKey);
241        if (!empty($certContent)) {
242            return $certContent;
243        }
244        /**
245         * Reference:
246         *
247         * https://github.com/php/php-src/issues/12128
248         * https://www.php.net/manual/en/function.openssl-pkcs12-read.php#128992
249         */
250        $msg = openssl_error_string();
251        if ($msg === 'error:0308010C:digital envelope routines::unsupported') {
252            $tempPassword = $this->tempManager->getTemporaryFile();
253            $tempEncriptedOriginal = $this->tempManager->getTemporaryFile();
254            $tempEncriptedRepacked = $this->tempManager->getTemporaryFile();
255            $tempDecrypted = $this->tempManager->getTemporaryFile();
256            file_put_contents($tempPassword, $privateKey);
257            file_put_contents($tempEncriptedOriginal, $certificate);
258            shell_exec(<<<REPACK_COMMAND
259                cat $tempPassword | openssl pkcs12 -legacy -in $tempEncriptedOriginal -nodes -out $tempDecrypted -passin stdin &&
260                cat $tempPassword | openssl pkcs12 -in $tempDecrypted -export -out $tempEncriptedRepacked -passout stdin
261                REPACK_COMMAND
262            );
263            $certificateRepacked = file_get_contents($tempEncriptedRepacked);
264            openssl_pkcs12_read($certificateRepacked, $certContent, $privateKey);
265            if (!empty($certContent)) {
266                $certificate = $certificateRepacked;
267                return $certContent;
268            }
269        }
270        throw new InvalidPasswordException();
271    }
272
273    /**
274     * @param (int|string) $name
275     *
276     * @psalm-param array-key $name
277     */
278    public function translateToLong($name): string {
279        return match ($name) {
280            'CN' => 'CommonName',
281            'C' => 'Country',
282            'ST' => 'State',
283            'L' => 'Locality',
284            'O' => 'Organization',
285            'OU' => 'OrganizationalUnit',
286            'UID' => 'UserIdentifier',
287            default => '',
288        };
289    }
290
291    #[\Override]
292    public function setEngine(string $engine): void {
293        $this->appConfig->setValueString(Application::APP_ID, 'certificate_engine', $engine);
294        $this->engine = $engine;
295        $this->configureIdentifyMethodsForEngine($engine);
296    }
297
298    /**
299     * Configure identification methods based on the certificate engine.
300     *
301     * When the engine is set to 'none', only the 'account' identification method
302     * is allowed. This is because:
303     * - The 'none' engine doesn't generate digital certificates
304     * - Without certificates, only basic password authentication is viable
305     * - The 'account' method ensures users authenticate with their Nextcloud credentials
306     *
307     * For other engines (openssl, cfssl, java), the identification methods remain
308     * unchanged to preserve existing configurations.
309     *
310     * @param string $engine The certificate engine name (i.e. 'none', 'openssl', 'cfssl')
311     */
312    private function configureIdentifyMethodsForEngine(string $engine): void {
313        if ($engine !== 'none') {
314            return;
315        }
316
317        $config = [[
318            'name' => 'account',
319            'enabled' => true,
320            'mandatory' => true,
321        ]];
322        $this->policyService->saveSystem(IdentifyMethodsPolicy::KEY, $config);
323    }
324
325    #[\Override]
326    public function getEngine(): string {
327        if ($this->engine) {
328            return $this->engine;
329        }
330        $configValues = $this->appConfig->getAllValues(Application::APP_ID);
331        $configuredEngine = $configValues['certificate_engine'] ?? '';
332        if (is_string($configuredEngine) && $configuredEngine !== '') {
333            $this->engine = $configuredEngine;
334            return $this->engine;
335        }
336        if (is_scalar($configuredEngine) && (string)$configuredEngine !== '') {
337            $this->engine = (string)$configuredEngine;
338            return $this->engine;
339        }
340        $this->engine = $this->appConfig->getValueString(Application::APP_ID, 'certificate_engine', 'openssl');
341        return $this->engine;
342    }
343
344    #[\Override]
345    public function populateInstance(array $rootCert): IEngineHandler {
346        if (empty($rootCert)) {
347            $rootCert = $this->appConfig->getValueArray(Application::APP_ID, 'rootCert');
348        }
349        if (!$rootCert) {
350            return $this;
351        }
352        if (!empty($rootCert['names'])) {
353            foreach ($rootCert['names'] as $id => $customName) {
354                $longCustomName = $this->translateToLong($id);
355                // Prevent to save a property that don't exists
356                if (!property_exists($this, lcfirst($longCustomName))) {
357                    continue;
358                }
359                $this->{'set' . ucfirst($longCustomName)}($customName['value']);
360            }
361        }
362        if (!$this->getCommonName()) {
363            $this->setCommonName($rootCert['commonName']);
364        }
365        return $this;
366    }
367
368    #[\Override]
369    public function getCurrentConfigPath(): string {
370        if ($this->configPath) {
371            return $this->configPath;
372        }
373
374        $customConfigPath = $this->appConfig->getValueString(Application::APP_ID, 'config_path');
375        if ($customConfigPath && is_dir($customConfigPath)) {
376            $this->configPath = $customConfigPath;
377            return $this->configPath;
378        }
379
380        $this->configPath = $this->initializePkiConfigPath();
381        if (!empty($this->configPath)) {
382            $this->appConfig->setValueString(Application::APP_ID, 'config_path', $this->configPath);
383        }
384        return $this->configPath;
385    }
386
387    #[\Override]
388    public function getConfigPathByParams(string $instanceId, int $generation): string {
389        $engineName = $this->getName();
390
391        $pkiDirName = $this->caIdentifierService->generatePkiDirectoryNameFromParams($instanceId, $generation, $engineName);
392        $dataDir = $this->config->getSystemValue('datadirectory', \OC::$SERVERROOT . '/data/');
393        $systemInstanceId = $this->config->getSystemValue('instanceid');
394        $pkiPath = $dataDir . '/appdata_' . $systemInstanceId . '/libresign/' . $pkiDirName;
395
396        if (!is_dir($pkiPath)) {
397            throw new \RuntimeException("Config path does not exist for instanceId: {$instanceId}, generation: {$generation}");
398        }
399
400        return $pkiPath;
401    }
402
403    private function initializePkiConfigPath(): string {
404        $caId = $this->getCaId();
405        if (empty($caId)) {
406            return '';
407        }
408        $pkiDirName = $this->caIdentifierService->generatePkiDirectoryName($caId);
409        $dataDir = $this->config->getSystemValue('datadirectory', \OC::$SERVERROOT . '/data/');
410        $systemInstanceId = $this->config->getSystemValue('instanceid');
411        $pkiPath = $dataDir . '/appdata_' . $systemInstanceId . '/libresign/' . $pkiDirName;
412
413        if (!is_dir($pkiPath)) {
414            $this->createDirectoryWithCorrectOwnership($pkiPath);
415        }
416
417        return $pkiPath;
418    }
419
420    private function createDirectoryWithCorrectOwnership(string $path): void {
421        $ownerInfo = $this->getFilesOwnerInfo();
422        $fullCommand = 'mkdir -p ' . escapeshellarg($path);
423
424        if (posix_getuid() !== $ownerInfo['uid']) {
425            $fullCommand = 'runuser -u ' . $ownerInfo['name'] . ' -- ' . $fullCommand;
426        }
427
428        exec($fullCommand);
429    }
430
431    private function getFilesOwnerInfo(): array {
432        $currentFile = realpath(__DIR__);
433        $owner = fileowner($currentFile);
434        if ($owner === false) {
435            throw new \RuntimeException('Unable to get file information');
436        }
437        $ownerInfo = posix_getpwuid($owner);
438        if ($ownerInfo === false) {
439            throw new \RuntimeException('Unable to get file owner information');
440        }
441
442        return $ownerInfo;
443    }
444
445    /**
446     * @todo check a best solution to don't use reflection
447     */
448    private function getInternalPathOfFolder(ISimpleFolder $node): string {
449        $reflection = new \ReflectionClass($node);
450        $reflectionProperty = $reflection->getProperty('folder');
451        $folder = $reflectionProperty->getValue($node);
452        $path = $folder->getInternalPath();
453        return $path;
454    }
455
456    #[\Override]
457    public function setConfigPath(string $configPath): IEngineHandler {
458        if (!$configPath) {
459            $this->appConfig->deleteKey(Application::APP_ID, 'config_path');
460        } else {
461            if (!is_dir($configPath)) {
462                mkdir(
463                    directory: $configPath,
464                    recursive: true,
465                );
466            }
467            $this->appConfig->setValueString(Application::APP_ID, 'config_path', $configPath);
468        }
469        $this->configPath = $configPath;
470        return $this;
471    }
472
473    public function getName(): string {
474        $reflect = new ReflectionClass($this);
475        $className = $reflect->getShortName();
476        $name = strtolower(substr($className, 0, -7));
477        return $name;
478    }
479
480    protected function getNames(): array {
481        $names = [
482            'C' => $this->getCountry(),
483            'ST' => $this->getState(),
484            'L' => $this->getLocality(),
485            'O' => $this->getOrganization(),
486            'OU' => $this->getOrganizationalUnit(),
487        ];
488        if ($uid = $this->getUID()) {
489            $names['UID'] = $uid;
490        }
491        $names = array_filter($names, fn ($v) => !empty($v));
492        return $names;
493    }
494
495    public function getUID(): string {
496        return str_replace(' ', '+', $this->UID);
497    }
498
499    #[\Override]
500    public function getLeafExpiryInDays(): int {
501        if ($this->leafExpiryOverrideInDays !== null) {
502            return $this->leafExpiryOverrideInDays;
503        }
504        $exp = (int)$this->policyService->resolve(ExpirationRulesPolicy::KEY_EXPIRY_IN_DAYS)->getEffectiveValue();
505        return $exp > 0 ? $exp : 365;
506    }
507
508    #[\Override]
509    public function setLeafExpiryOverrideInDays(?int $days): self {
510        $this->leafExpiryOverrideInDays = ($days !== null && $days > 0) ? $days : null;
511        return $this;
512    }
513
514    #[\Override]
515    public function getCaExpiryInDays(): int {
516        $exp = $this->appConfig->getValueInt(Application::APP_ID, 'ca_expiry_in_days', 3650); // 10 years
517        return $exp > 0 ? $exp : 3650;
518    }
519
520    private function getCertificatePolicy(): array {
521        $return = ['policySection' => []];
522        $oid = $this->certificatePolicyService->getOid();
523        $cps = $this->certificatePolicyService->getCps();
524        if ($oid && $cps) {
525            $return['policySection'][] = [
526                'OID' => $oid,
527                'CPS' => $cps,
528            ];
529        }
530        return $return;
531    }
532
533    abstract protected function getConfigureCheckResourceName(): string;
534
535    abstract protected function getCertificateRegenerationTip(): string;
536
537    abstract protected function getEngineSpecificChecks(): array;
538
539    abstract protected function getSetupSuccessMessage(): string;
540
541    abstract protected function getSetupErrorMessage(): string;
542
543    abstract protected function getSetupErrorTip(): string;
544
545    #[\Override]
546    public function configureCheck(): array {
547        $checks = $this->getEngineSpecificChecks();
548
549        if (!$this->isSetupOk()) {
550            return array_merge($checks, [
551                (new ConfigureCheckHelper())
552                    ->setErrorMessage($this->getSetupErrorMessage())
553                    ->setResource($this->getConfigureCheckResourceName())
554                    ->setTip($this->getSetupErrorTip())
555            ]);
556        }
557
558        $checks[] = (new ConfigureCheckHelper())
559            ->setSuccessMessage($this->getSetupSuccessMessage())
560            ->setResource($this->getConfigureCheckResourceName());
561
562        $modernFeaturesCheck = $this->checkRootCertificateModernFeatures();
563        if ($modernFeaturesCheck) {
564            $checks[] = $modernFeaturesCheck;
565        }
566
567        $expiryCheck = $this->checkRootCertificateExpiry();
568        if ($expiryCheck) {
569            $checks[] = $expiryCheck;
570        }
571
572        return $checks;
573    }
574
575    protected function checkRootCertificateModernFeatures(): ?ConfigureCheckHelper {
576        $configPath = $this->getCurrentConfigPath();
577        $caCertPath = $configPath . DIRECTORY_SEPARATOR . 'ca.pem';
578
579        try {
580            $certContent = file_get_contents($caCertPath);
581            if (!$certContent) {
582                return (new ConfigureCheckHelper())
583                    ->setErrorMessage('Failed to read root certificate file')
584                    ->setResource($this->getConfigureCheckResourceName())
585                    ->setTip('Check file permissions and disk space');
586            }
587
588            $x509Resource = openssl_x509_read($certContent);
589            if (!$x509Resource) {
590                return (new ConfigureCheckHelper())
591                    ->setErrorMessage('Failed to parse root certificate')
592                    ->setResource($this->getConfigureCheckResourceName())
593                    ->setTip('Root certificate file may be corrupted or invalid');
594            }
595
596            $parsed = openssl_x509_parse($x509Resource);
597            if (!$parsed) {
598                return (new ConfigureCheckHelper())
599                    ->setErrorMessage('Failed to extract root certificate information')
600                    ->setResource($this->getConfigureCheckResourceName())
601                    ->setTip('Root certificate may be in an unsupported format');
602            }
603
604            $criticalIssues = [];
605            $minorIssues = [];
606
607            if (isset($parsed['serialNumber'])) {
608                $serialNumber = $parsed['serialNumber'];
609                $serialDecimal = hexdec($serialNumber);
610                if ($serialDecimal <= 1) {
611                    $minorIssues[] = 'Serial number is simple (zero or one)';
612                }
613            } else {
614                $criticalIssues[] = 'Serial number is missing';
615            }
616
617            $missingExtensions = [];
618            if (!isset($parsed['extensions']['subjectKeyIdentifier'])) {
619                $missingExtensions[] = 'Subject Key Identifier (SKI)';
620            }
621
622            $isSelfSigned = (isset($parsed['issuer']) && isset($parsed['subject'])
623                            && $parsed['issuer'] === $parsed['subject']);
624
625            /**
626             * @todo workarround for missing AKI at certificates generated by CFSSL.
627             *
628             * CFSSL does not add Authority Key Identifier (AKI) to self-signed root certificates.
629             */
630            if (!$isSelfSigned && !isset($parsed['extensions']['authorityKeyIdentifier'])) {
631                $missingExtensions[] = 'Authority Key Identifier (AKI)';
632            }
633
634            if (!isset($parsed['extensions']['crlDistributionPoints'])) {
635                $missingExtensions[] = 'CRL Distribution Points';
636            }
637
638            if (!empty($missingExtensions)) {
639                $extensionsList = implode(', ', $missingExtensions);
640                $minorIssues[] = "Missing modern extensions: {$extensionsList}";
641            }
642
643            $hasLibresignCaUuid = $this->validateLibresignCaUuidInCertificate($parsed);
644            if (!$hasLibresignCaUuid) {
645                $minorIssues[] = 'LibreSign CA UUID not found in Organizational Unit';
646            }
647
648            if (!empty($criticalIssues)) {
649                $issuesList = implode(', ', $criticalIssues);
650                return (new ConfigureCheckHelper())
651                    ->setErrorMessage("Root certificate has critical issues: {$issuesList}")
652                    ->setResource($this->getConfigureCheckResourceName())
653                    ->setTip($this->getCertificateRegenerationTip());
654            }
655
656            if (!empty($minorIssues)) {
657                $issuesList = implode(', ', $minorIssues);
658                return (new ConfigureCheckHelper())
659                    ->setInfoMessage("Root certificate could benefit from modern features: {$issuesList}")
660                    ->setResource($this->getConfigureCheckResourceName())
661                    ->setTip($this->getCertificateRegenerationTip() . ' (recommended but not required)');
662            }
663
664            return null;
665        } catch (\Exception $e) {
666            return (new ConfigureCheckHelper())
667                ->setErrorMessage('Failed to analyze root certificate: ' . $e->getMessage())
668                ->setResource($this->getConfigureCheckResourceName())
669                ->setTip('Check if the root certificate file is valid');
670        }
671    }
672
673    private function validateLibresignCaUuidInCertificate(array $parsed): bool {
674        if (!isset($parsed['subject']['OU'])) {
675            return false;
676        }
677
678        $instanceId = $this->getLibreSignInstanceId();
679        if (empty($instanceId)) {
680            return false;
681        }
682
683        $organizationalUnits = $parsed['subject']['OU'];
684
685        if (is_string($organizationalUnits)) {
686            if (str_contains($organizationalUnits, ', ')) {
687                $organizationalUnits = explode(', ', $organizationalUnits);
688            } else {
689                $organizationalUnits = [$organizationalUnits];
690            }
691        }
692
693        foreach ($organizationalUnits as $ou) {
694            $ou = trim((string)$ou);
695            if ($this->caIdentifierService->isValidCaId($ou, $instanceId)) {
696                return true;
697            }
698        }
699
700        return false;
701    }
702
703    private function getLibreSignInstanceId(): string {
704        $instanceId = $this->appConfig->getValueString(Application::APP_ID, 'instance_id', '');
705        if (strlen($instanceId) === 10) {
706            return $instanceId;
707        }
708        return '';
709    }
710
711    private function calculateRemainingDays(int $validToTimestamp): int {
712        $secondsPerDay = 60 * 60 * 24;
713        $remainingSeconds = $validToTimestamp - time();
714        return (int)ceil($remainingSeconds / $secondsPerDay);
715    }
716
717    protected function checkRootCertificateExpiry(): ?ConfigureCheckHelper {
718        $configPath = $this->getCurrentConfigPath();
719        $caCertPath = $configPath . DIRECTORY_SEPARATOR . 'ca.pem';
720
721        if (!file_exists($caCertPath)) {
722            return null;
723        }
724
725        $certContent = file_get_contents($caCertPath);
726        if (!$certContent) {
727            return null;
728        }
729
730        $x509Resource = openssl_x509_read($certContent);
731        if (!$x509Resource) {
732            return null;
733        }
734
735        $parsed = openssl_x509_parse($x509Resource);
736        if (!$parsed) {
737            return null;
738        }
739
740        $remainingDays = $this->calculateRemainingDays($parsed['validTo_time_t']);
741        $leafExpiryDays = $this->getLeafExpiryInDays();
742
743        if ($remainingDays < 0) {
744            return (new ConfigureCheckHelper())
745                ->setErrorMessage('Root certificate has expired')
746                ->setResource($this->getConfigureCheckResourceName())
747                ->setTip($this->getCertificateRegenerationTip() . ' URGENT: Certificate is expired!');
748        }
749
750        if ($remainingDays <= 7) {
751            return (new ConfigureCheckHelper())
752                ->setErrorMessage("Root certificate expires in {$remainingDays} days")
753                ->setResource($this->getConfigureCheckResourceName())
754                ->setTip($this->getCertificateRegenerationTip() . ' URGENT: Renew immediately!');
755        }
756
757        if ($remainingDays <= 30) {
758            return (new ConfigureCheckHelper())
759                ->setErrorMessage("Root certificate expires in {$remainingDays} days")
760                ->setResource($this->getConfigureCheckResourceName())
761                ->setTip($this->getCertificateRegenerationTip() . ' Renewal recommended soon.');
762        }
763
764        if ($remainingDays <= $leafExpiryDays) {
765            return (new ConfigureCheckHelper())
766                ->setInfoMessage("Root certificate expires in {$remainingDays} days (leaf validity: {$leafExpiryDays} days)")
767                ->setResource($this->getConfigureCheckResourceName())
768                ->setTip('Root certificate should be renewed to ensure it can sign CRLs for all issued leaf certificates.');
769        }
770
771        return null;
772    }
773
774    #[\Override]
775    public function toArray(): array {
776        $generated = $this->isSetupOk();
777        $return = [
778            'configPath' => $this->getConfigPathForApi($generated),
779            'generated' => $generated,
780            'rootCert' => [
781                'commonName' => $this->getCommonName(),
782                'names' => [],
783            ],
784        ];
785        $return = array_merge(
786            $return,
787            $this->getCertificatePolicy(),
788        );
789        $names = $this->getNames();
790        foreach ($names as $name => $value) {
791            $filteredValue = $this->filterNameValue($name, $value, $generated);
792            if ($filteredValue === null) {
793                continue;
794            }
795            $return['rootCert']['names'][] = [
796                'id' => $name,
797                'value' => $filteredValue,
798            ];
799        }
800        return $return;
801    }
802
803    private function getConfigPathForApi(bool $generated): string {
804        return $generated ? $this->getCurrentConfigPath() : '';
805    }
806
807    private function filterNameValue(string $name, mixed $value, bool $generated): mixed {
808        if ($name === 'OU' && is_array($value)) {
809            if (!$generated) {
810                $value = $this->removeCaIdFromOrganizationalUnit($value);
811            }
812            return empty($value) ? null : implode(', ', $value);
813        }
814        return $value;
815    }
816
817    private function removeCaIdFromOrganizationalUnit(array $organizationalUnits): array {
818        $filtered = array_filter(
819            $organizationalUnits,
820            fn ($item) => !str_starts_with((string)$item, 'libresign-ca-id:')
821        );
822        return array_values($filtered);
823    }
824
825    protected function getCrlDistributionUrl(): string {
826        $caIdParsed = $this->caIdentifierService->getCaIdParsed();
827        return $this->urlGenerator->linkToRouteAbsolute('libresign.crl.getRevocationList', [
828            'instanceId' => $caIdParsed['instanceId'],
829            'generation' => $caIdParsed['generation'],
830            'engineType' => $caIdParsed['engineType'],
831        ]);
832    }
833
834    #[\Override]
835    public function generateCrlDer(array $revokedCertificates, string $instanceId, int $generation, int $crlNumber): string {
836        $configPath = $this->getConfigPathByParams($instanceId, $generation);
837        [$issuer, $privateKey] = $this->loadCaIssuer($configPath);
838        return $this->createAndSignCrl($issuer, $privateKey, $revokedCertificates, $crlNumber);
839    }
840
841    /** @return array{0: \OCA\Libresign\Vendor\phpseclib4\File\X509, 1: \OCA\Libresign\Vendor\phpseclib4\Crypt\Common\PrivateKey} */
842    private function loadCaIssuer(string $configPath): array {
843        $caCertPath = $configPath . DIRECTORY_SEPARATOR . 'ca.pem';
844        $caKeyPath = $configPath . DIRECTORY_SEPARATOR . 'ca-key.pem';
845
846        if (!file_exists($caCertPath) || !file_exists($caKeyPath)) {
847            $this->logger->error('CA certificate or private key not found', ['caCertPath' => $caCertPath, 'caKeyPath' => $caKeyPath]);
848            throw new \RuntimeException('CA certificate or private key not found. Run: occ libresign:configure:openssl');
849        }
850
851        $caCert = file_get_contents($caCertPath);
852        $caKey = file_get_contents($caKeyPath);
853
854        if (!$caCert || !$caKey) {
855            $this->logger->error('Failed to read CA certificate or private key', ['caCertPath' => $caCertPath, 'caKeyPath' => $caKeyPath]);
856            throw new \RuntimeException('Failed to read CA certificate or private key');
857        }
858
859        $issuer = \OCA\Libresign\Vendor\phpseclib4\File\X509::load($caCert);
860        $caPrivateKey = \OCA\Libresign\Vendor\phpseclib4\Crypt\PublicKeyLoader::load($caKey);
861
862        if (!$caPrivateKey instanceof \OCA\Libresign\Vendor\phpseclib4\Crypt\Common\PrivateKey) {
863            $this->logger->error('Loaded key is not a private key', ['keyType' => $caPrivateKey::class]);
864            throw new \RuntimeException('Loaded key is not a private key');
865        }
866
867        return [$issuer, $caPrivateKey];
868    }
869
870    private function createAndSignCrl(\OCA\Libresign\Vendor\phpseclib4\File\X509 $issuer, \OCA\Libresign\Vendor\phpseclib4\Crypt\Common\PrivateKey $privateKey, array $revokedCertificates, int $crlNumber): string {
871        $utcZone = new \DateTimeZone('UTC');
872        $crl = new \OCA\Libresign\Vendor\phpseclib4\File\CRL();
873        $crl->copySigningX509Attributes($issuer);
874        $crl->setThisDate(new \DateTime('now', $utcZone));
875        $crl->setNextDate(new \DateTime('+7 days', $utcZone));
876        $crl->setExtension('id-ce-cRLNumber', new \OCA\Libresign\Vendor\phpseclib4\Math\BigInteger((string)$crlNumber));
877
878        foreach ($revokedCertificates as $cert) {
879            $crl->revoke(
880                $this->createRevokedCertificateSerial((string)$cert->getSerialNumber(), $cert->getEngine()),
881                null,
882                $cert->getRevokedAt(),
883            );
884        }
885
886        $crl->identifySignatureAlgorithm($privateKey);
887        $signature = $privateKey->sign($crl);
888        if (!is_string($signature)) {
889            throw new \RuntimeException('Failed to sign CRL');
890        }
891        $crl->setSignature($signature);
892        return $crl->toString(['binary' => true]);
893    }
894
895    private function createRevokedCertificateSerial(string $serialNumber, string $engine): \OCA\Libresign\Vendor\phpseclib4\Math\BigInteger {
896        $normalizedSerial = ltrim($serialNumber, '0') ?: '0';
897        $base = match ($engine) {
898            'openssl' => 10,
899            'cfssl' => 16,
900            default => ctype_digit($normalizedSerial) ? 10 : 16,
901        };
902
903        return new \OCA\Libresign\Vendor\phpseclib4\Math\BigInteger($normalizedSerial, $base);
904    }
905
906    #[\Override]
907    public function validateRootCertificate(): void {
908        $configPath = $this->getCurrentConfigPath();
909        if (empty($configPath)) {
910            return;
911        }
912
913        if (!is_dir($configPath)) {
914            return;
915        }
916
917        $rootCertPath = $configPath . DIRECTORY_SEPARATOR . 'ca.pem';
918
919        if (!file_exists($rootCertPath)) {
920            return;
921        }
922
923        $rootCert = file_get_contents($rootCertPath);
924        if (empty($rootCert)) {
925            return;
926        }
927
928        $certificate = openssl_x509_read($rootCert);
929        if ($certificate === false) {
930            throw new LibresignException('Invalid root certificate content');
931        }
932        $certInfo = openssl_x509_parse($certificate);
933        if ($certInfo === false) {
934            throw new LibresignException('Failed to parse root certificate');
935        }
936
937        if ($this->checkCertificateRevoked($certInfo['serialNumber'])) {
938            $this->logger->error('Root certificate has been revoked', [
939                'ca_id' => $this->getCaId(),
940                'impact' => 'all_leaf_certificates_invalid',
941            ]);
942            throw new LibresignException(
943                'Root certificate has been revoked. Please contact the administrator to regenerate the signing certificate.',
944                \OC\AppFramework\Http::STATUS_PRECONDITION_FAILED
945            );
946        }
947
948        if ($certInfo['validTo_time_t'] < time()) {
949            $this->logger->error('Root certificate has expired', [
950                'ca_id' => $this->getCaId(),
951            ]);
952            throw new LibresignException(
953                'Root certificate has expired. Please contact the administrator to regenerate the signing certificate.',
954                \OC\AppFramework\Http::STATUS_PRECONDITION_FAILED
955            );
956        }
957
958        $remainingDays = $this->calculateRemainingDays($certInfo['validTo_time_t']);
959        $leafExpiryDays = $this->getLeafExpiryInDays();
960
961        if ($remainingDays <= $leafExpiryDays) {
962            $this->logger->warning('Root certificate renewal needed', [
963                'remaining_days' => $remainingDays,
964                'leaf_expiry_days' => $leafExpiryDays,
965            ]);
966        }
967    }
968
969    private function checkCertificateRevoked(string $serialNumber): bool {
970        try {
971            /** @var \OCA\Libresign\Service\Crl\CrlService */
972            $crlService = \OCP\Server::get(\OCA\Libresign\Service\Crl\CrlService::class);
973            $status = $crlService->getCertificateStatus($serialNumber);
974            return $status['status'] === 'revoked';
975        } catch (\Exception $e) {
976            $this->logger->warning('Failed to check root certificate revocation status', [
977                'error' => $e->getMessage()
978            ]);
979            return false;
980        }
981    }
982}