Code Coverage |
||||||||||
Lines |
Functions and Methods |
Classes and Traits |
||||||||
| Total | |
66.47% |
335 / 504 |
|
42.55% |
20 / 47 |
CRAP | |
0.00% |
0 / 1 |
| AEngineHandler | |
66.47% |
335 / 504 |
|
42.55% |
20 / 47 |
1245.82 | |
0.00% |
0 / 1 |
| __construct | |
100.00% |
2 / 2 |
|
100.00% |
1 / 1 |
1 | |||
| exportToPkcs12 | |
80.00% |
12 / 15 |
|
0.00% |
0 / 1 |
5.20 | |||
| updatePassword | |
0.00% |
0 / 6 |
|
0.00% |
0 / 1 |
20 | |||
| readCertificate | |
100.00% |
9 / 9 |
|
100.00% |
1 / 1 |
5 | |||
| getCaId | |
100.00% |
5 / 5 |
|
100.00% |
1 / 1 |
3 | |||
| parseCertificate | |
100.00% |
1 / 1 |
|
100.00% |
1 / 1 |
1 | |||
| setPolicyUserIdForValidation | |
75.00% |
3 / 4 |
|
0.00% |
0 / 1 |
3.14 | |||
| parseX509 | |
100.00% |
11 / 11 |
|
100.00% |
1 / 1 |
5 | |||
| addCrlValidationInfo | |
77.78% |
14 / 18 |
|
0.00% |
0 / 1 |
6.40 | |||
| convertArrayToUtf8 | |
100.00% |
6 / 6 |
|
100.00% |
1 / 1 |
4 | |||
| opensslPkcs12Read | |
27.27% |
6 / 22 |
|
0.00% |
0 / 1 |
10.15 | |||
| translateToLong | |
60.00% |
6 / 10 |
|
0.00% |
0 / 1 |
1.06 | |||
| setEngine | |
100.00% |
3 / 3 |
|
100.00% |
1 / 1 |
1 | |||
| configureIdentifyMethodsForEngine | |
100.00% |
8 / 8 |
|
100.00% |
1 / 1 |
2 | |||
| getEngine | |
16.67% |
2 / 12 |
|
0.00% |
0 / 1 |
26.83 | |||
| populateInstance | |
30.77% |
4 / 13 |
|
0.00% |
0 / 1 |
23.26 | |||
| getCurrentConfigPath | |
100.00% |
10 / 10 |
|
100.00% |
1 / 1 |
5 | |||
| getConfigPathByParams | |
87.50% |
7 / 8 |
|
0.00% |
0 / 1 |
2.01 | |||
| initializePkiConfigPath | |
90.00% |
9 / 10 |
|
0.00% |
0 / 1 |
3.01 | |||
| createDirectoryWithCorrectOwnership | |
80.00% |
4 / 5 |
|
0.00% |
0 / 1 |
2.03 | |||
| getFilesOwnerInfo | |
75.00% |
6 / 8 |
|
0.00% |
0 / 1 |
3.14 | |||
| getInternalPathOfFolder | |
0.00% |
0 / 5 |
|
0.00% |
0 / 1 |
2 | |||
| setConfigPath | |
50.00% |
5 / 10 |
|
0.00% |
0 / 1 |
4.12 | |||
| getName | |
100.00% |
4 / 4 |
|
100.00% |
1 / 1 |
1 | |||
| getNames | |
90.91% |
10 / 11 |
|
0.00% |
0 / 1 |
2.00 | |||
| getUID | |
100.00% |
1 / 1 |
|
100.00% |
1 / 1 |
1 | |||
| getLeafExpiryInDays | |
75.00% |
3 / 4 |
|
0.00% |
0 / 1 |
3.14 | |||
| setLeafExpiryOverrideInDays | |
0.00% |
0 / 2 |
|
0.00% |
0 / 1 |
12 | |||
| getCaExpiryInDays | |
100.00% |
2 / 2 |
|
100.00% |
1 / 1 |
2 | |||
| getCertificatePolicy | |
55.56% |
5 / 9 |
|
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% |
12 / 18 |
|
0.00% |
0 / 1 |
4.59 | |||
| checkRootCertificateModernFeatures | |
50.82% |
31 / 61 |
|
0.00% |
0 / 1 |
51.38 | |||
| validateLibresignCaUuidInCertificate | |
13.33% |
2 / 15 |
|
0.00% |
0 / 1 |
38.90 | |||
| getLibreSignInstanceId | |
0.00% |
0 / 4 |
|
0.00% |
0 / 1 |
6 | |||
| calculateRemainingDays | |
100.00% |
3 / 3 |
|
100.00% |
1 / 1 |
1 | |||
| checkRootCertificateExpiry | |
77.78% |
28 / 36 |
|
0.00% |
0 / 1 |
9.89 | |||
| toArray | |
100.00% |
23 / 23 |
|
100.00% |
1 / 1 |
3 | |||
| getConfigPathForApi | |
100.00% |
1 / 1 |
|
100.00% |
1 / 1 |
2 | |||
| filterNameValue | |
100.00% |
5 / 5 |
|
100.00% |
1 / 1 |
5 | |||
| removeCaIdFromOrganizationalUnit | |
100.00% |
5 / 5 |
|
100.00% |
1 / 1 |
1 | |||
| getCrlDistributionUrl | |
100.00% |
6 / 6 |
|
100.00% |
1 / 1 |
1 | |||
| generateCrlDer | |
100.00% |
3 / 3 |
|
100.00% |
1 / 1 |
1 | |||
| loadCaIssuer | |
62.50% |
10 / 16 |
|
0.00% |
0 / 1 |
7.90 | |||
| createAndSignCrl | |
94.44% |
17 / 18 |
|
0.00% |
0 / 1 |
3.00 | |||
| createRevokedCertificateSerial | |
100.00% |
7 / 7 |
|
100.00% |
1 / 1 |
3 | |||
| validateRootCertificate | |
51.22% |
21 / 41 |
|
0.00% |
0 / 1 |
21.61 | |||
| checkCertificateRevoked | |
37.50% |
3 / 8 |
|
0.00% |
0 / 1 |
2.98 | |||
| 1 | <?php |
| 2 | |
| 3 | declare(strict_types=1); |
| 4 | /** |
| 5 | * SPDX-FileCopyrightText: 2020-2024 LibreCode coop and contributors |
| 6 | * SPDX-License-Identifier: AGPL-3.0-or-later |
| 7 | */ |
| 8 | |
| 9 | namespace OCA\Libresign\Handler\CertificateEngine; |
| 10 | |
| 11 | use OCA\Libresign\AppInfo\Application; |
| 12 | use OCA\Libresign\Enum\CrlValidationStatus; |
| 13 | use OCA\Libresign\Exception\EmptyCertificateException; |
| 14 | use OCA\Libresign\Exception\InvalidPasswordException; |
| 15 | use OCA\Libresign\Exception\LibresignException; |
| 16 | use OCA\Libresign\Helper\ConfigureCheckHelper; |
| 17 | use OCA\Libresign\Helper\MagicGetterSetterTrait; |
| 18 | use OCA\Libresign\Service\CaIdentifierService; |
| 19 | use OCA\Libresign\Service\CertificatePolicyService; |
| 20 | use OCA\Libresign\Service\Crl\CrlDistributionPointsExtractor; |
| 21 | use OCA\Libresign\Service\Crl\CrlRevocationChecker; |
| 22 | use OCA\Libresign\Service\Policy\PolicyService; |
| 23 | use OCA\Libresign\Service\Policy\Provider\ExpirationRules\ExpirationRulesPolicy; |
| 24 | use OCA\Libresign\Service\Policy\Provider\IdentifyMethods\IdentifyMethodsPolicy; |
| 25 | use OCP\Files\AppData\IAppDataFactory; |
| 26 | use OCP\Files\IAppData; |
| 27 | use OCP\Files\SimpleFS\ISimpleFolder; |
| 28 | use OCP\IAppConfig; |
| 29 | use OCP\IConfig; |
| 30 | use OCP\IDateTimeFormatter; |
| 31 | use OCP\ITempManager; |
| 32 | use OCP\IURLGenerator; |
| 33 | use OpenSSLAsymmetricKey; |
| 34 | use OpenSSLCertificate; |
| 35 | use Psr\Log\LoggerInterface; |
| 36 | use 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 | */ |
| 60 | abstract 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 | } |