Code Coverage |
||||||||||
Lines |
Functions and Methods |
Classes and Traits |
||||||||
| Total | |
82.18% |
166 / 202 |
|
42.11% |
8 / 19 |
CRAP | |
0.00% |
0 / 1 |
| OrderCertificatesTrait | |
82.18% |
166 / 202 |
|
42.11% |
8 / 19 |
148.17 | |
0.00% |
0 / 1 |
| orderCertificates | |
100.00% |
6 / 6 |
|
100.00% |
1 / 1 |
3 | |||
| buildChain | |
94.74% |
18 / 19 |
|
0.00% |
0 / 1 |
7.01 | |||
| ensureValidStructure | |
100.00% |
7 / 7 |
|
100.00% |
1 / 1 |
7 | |||
| findLeafCertificate | |
88.89% |
8 / 9 |
|
0.00% |
0 / 1 |
6.05 | |||
| isRootCertificate | |
100.00% |
3 / 3 |
|
100.00% |
1 / 1 |
4 | |||
| isIssuedBy | |
83.33% |
5 / 6 |
|
0.00% |
0 / 1 |
5.12 | |||
| validateAkiSkiMatch | |
77.78% |
14 / 18 |
|
0.00% |
0 / 1 |
12.33 | |||
| normalizeDistinguishedName | |
100.00% |
2 / 2 |
|
100.00% |
1 / 1 |
1 | |||
| validateCertificateChain | |
100.00% |
6 / 6 |
|
100.00% |
1 / 1 |
1 | |||
| isValidChain | |
100.00% |
8 / 8 |
|
100.00% |
1 / 1 |
9 | |||
| hasRootCertificate | |
100.00% |
4 / 4 |
|
100.00% |
1 / 1 |
3 | |||
| isCompleteChain | |
85.71% |
6 / 7 |
|
0.00% |
0 / 1 |
4.05 | |||
| validateChainExtensions | |
100.00% |
12 / 12 |
|
100.00% |
1 / 1 |
3 | |||
| validateCertificateExtensions | |
78.57% |
22 / 28 |
|
0.00% |
0 / 1 |
5.25 | |||
| validateBasicConstraints | |
46.15% |
6 / 13 |
|
0.00% |
0 / 1 |
11.62 | |||
| validateKeyUsage | |
68.75% |
11 / 16 |
|
0.00% |
0 / 1 |
7.10 | |||
| validateAuthorityKeyIdentifier | |
69.23% |
9 / 13 |
|
0.00% |
0 / 1 |
5.73 | |||
| validateSubjectKeyIdentifier | |
77.78% |
7 / 9 |
|
0.00% |
0 / 1 |
3.10 | |||
| validateAkiMatchesIssuerSki | |
75.00% |
12 / 16 |
|
0.00% |
0 / 1 |
7.77 | |||
| 1 | <?php |
| 2 | |
| 3 | declare(strict_types=1); |
| 4 | /** |
| 5 | * SPDX-FileCopyrightText: 2025 LibreCode coop and contributors |
| 6 | * SPDX-License-Identifier: AGPL-3.0-or-later |
| 7 | */ |
| 8 | |
| 9 | namespace OCA\Libresign\Handler\CertificateEngine; |
| 10 | |
| 11 | use InvalidArgumentException; |
| 12 | |
| 13 | trait OrderCertificatesTrait { |
| 14 | public function orderCertificates(array $certificates): array { |
| 15 | if (empty($certificates)) { |
| 16 | throw new InvalidArgumentException('Certificate list cannot be empty'); |
| 17 | } |
| 18 | |
| 19 | $this->ensureValidStructure($certificates); |
| 20 | |
| 21 | if (count($certificates) === 1) { |
| 22 | return $certificates; |
| 23 | } |
| 24 | |
| 25 | return $this->buildChain($certificates); |
| 26 | } |
| 27 | |
| 28 | private function buildChain(array $certificates): array { |
| 29 | $leaf = $this->findLeafCertificate($certificates); |
| 30 | if (!$leaf) { |
| 31 | return $certificates; |
| 32 | } |
| 33 | |
| 34 | $ordered = [$leaf]; |
| 35 | $remaining = array_filter($certificates, fn ($cert) => $cert['name'] !== $leaf['name']); |
| 36 | $remaining = array_values($remaining); |
| 37 | |
| 38 | while ($remaining && !$this->isRootCertificate(end($ordered))) { |
| 39 | $current = end($ordered); |
| 40 | $issuer = null; |
| 41 | |
| 42 | foreach ($remaining as $index => $candidate) { |
| 43 | if ($this->isIssuedBy($current, $candidate)) { |
| 44 | $issuer = $candidate; |
| 45 | unset($remaining[$index]); |
| 46 | $remaining = array_values($remaining); |
| 47 | break; |
| 48 | } |
| 49 | } |
| 50 | |
| 51 | if (!$issuer) { |
| 52 | break; |
| 53 | } |
| 54 | |
| 55 | $ordered[] = $issuer; |
| 56 | } |
| 57 | |
| 58 | return [...$ordered, ...$remaining]; |
| 59 | } |
| 60 | |
| 61 | private function ensureValidStructure(array $certificates): void { |
| 62 | foreach ($certificates as $cert) { |
| 63 | if (!is_array($cert) || !isset($cert['subject'], $cert['issuer'], $cert['name']) |
| 64 | || !is_array($cert['subject']) || !is_array($cert['issuer'])) { |
| 65 | throw new InvalidArgumentException('Invalid certificate structure. Certificate must have "subject", "issuer", and "name".'); |
| 66 | } |
| 67 | } |
| 68 | |
| 69 | $names = array_column($certificates, 'name'); |
| 70 | if (count($names) !== count(array_unique($names))) { |
| 71 | throw new InvalidArgumentException('Duplicate certificate names detected'); |
| 72 | } |
| 73 | } |
| 74 | |
| 75 | private function findLeafCertificate(array $certificates): ?array { |
| 76 | foreach ($certificates as $cert) { |
| 77 | $isLeaf = true; |
| 78 | foreach ($certificates as $other) { |
| 79 | if ($cert['name'] !== $other['name'] && $this->isIssuedBy($other, $cert)) { |
| 80 | $isLeaf = false; |
| 81 | break; |
| 82 | } |
| 83 | } |
| 84 | if ($isLeaf) { |
| 85 | return $cert; |
| 86 | } |
| 87 | } |
| 88 | return $certificates[0] ?? null; |
| 89 | } |
| 90 | |
| 91 | private function isRootCertificate(array $cert): bool { |
| 92 | if (!isset($cert['subject'], $cert['issuer']) || !is_array($cert['subject']) || !is_array($cert['issuer'])) { |
| 93 | return false; |
| 94 | } |
| 95 | return $this->normalizeDistinguishedName($cert['subject']) === $this->normalizeDistinguishedName($cert['issuer']); |
| 96 | } |
| 97 | |
| 98 | private function isIssuedBy(array $child, array $parent): bool { |
| 99 | if (!isset($child['issuer'], $parent['subject']) || !is_array($child['issuer']) || !is_array($parent['subject'])) { |
| 100 | return false; |
| 101 | } |
| 102 | |
| 103 | $dnMatch = $this->normalizeDistinguishedName($child['issuer']) === $this->normalizeDistinguishedName($parent['subject']); |
| 104 | |
| 105 | if (!$dnMatch) { |
| 106 | return false; |
| 107 | } |
| 108 | |
| 109 | return $this->validateAkiSkiMatch($child, $parent); |
| 110 | } |
| 111 | |
| 112 | private function validateAkiSkiMatch(array $child, array $parent): bool { |
| 113 | if (!isset($child['name']) || !isset($parent['name'])) { |
| 114 | return true; |
| 115 | } |
| 116 | |
| 117 | if (!file_exists($child['name']) || !file_exists($parent['name'])) { |
| 118 | return true; |
| 119 | } |
| 120 | |
| 121 | $childContent = file_get_contents($child['name']); |
| 122 | $parentContent = file_get_contents($parent['name']); |
| 123 | |
| 124 | if ($childContent === false || $parentContent === false) { |
| 125 | return true; |
| 126 | } |
| 127 | |
| 128 | $childData = openssl_x509_parse($childContent); |
| 129 | $parentData = openssl_x509_parse($parentContent); |
| 130 | |
| 131 | if (!$childData || !$parentData) { |
| 132 | return true; |
| 133 | } |
| 134 | |
| 135 | if (!isset($childData['extensions']['authorityKeyIdentifier']) |
| 136 | || !isset($parentData['extensions']['subjectKeyIdentifier'])) { |
| 137 | return true; |
| 138 | } |
| 139 | |
| 140 | $childAki = $childData['extensions']['authorityKeyIdentifier']; |
| 141 | $parentSki = $parentData['extensions']['subjectKeyIdentifier']; |
| 142 | |
| 143 | return str_contains($childAki, $parentSki); |
| 144 | } |
| 145 | |
| 146 | private function normalizeDistinguishedName(array $dn): string { |
| 147 | ksort($dn); |
| 148 | return json_encode($dn, JSON_THROW_ON_ERROR); |
| 149 | } |
| 150 | |
| 151 | public function validateCertificateChain(array $certificates): array { |
| 152 | return [ |
| 153 | 'valid' => $this->isValidChain($certificates), |
| 154 | 'hasRoot' => $this->hasRootCertificate($certificates), |
| 155 | 'isComplete' => $this->isCompleteChain($certificates), |
| 156 | 'length' => count($certificates), |
| 157 | ]; |
| 158 | } |
| 159 | |
| 160 | private function isValidChain(array $certificates): bool { |
| 161 | if (empty($certificates)) { |
| 162 | return false; |
| 163 | } |
| 164 | |
| 165 | foreach ($certificates as $cert) { |
| 166 | if (!is_array($cert) || !isset($cert['subject'], $cert['issuer'], $cert['name']) |
| 167 | || !is_array($cert['subject']) || !is_array($cert['issuer']) |
| 168 | || empty($cert['subject']['CN']) || empty($cert['issuer']['CN'])) { |
| 169 | return false; |
| 170 | } |
| 171 | } |
| 172 | |
| 173 | return true; |
| 174 | } |
| 175 | |
| 176 | private function hasRootCertificate(array $certificates): bool { |
| 177 | foreach ($certificates as $cert) { |
| 178 | if ($this->isRootCertificate($cert)) { |
| 179 | return true; |
| 180 | } |
| 181 | } |
| 182 | return false; |
| 183 | } |
| 184 | |
| 185 | private function isCompleteChain(array $certificates): bool { |
| 186 | if (!$this->hasRootCertificate($certificates)) { |
| 187 | return false; |
| 188 | } |
| 189 | |
| 190 | $ordered = $this->orderCertificates($certificates); |
| 191 | for ($i = 0; $i < count($ordered) - 1; $i++) { |
| 192 | if (!$this->isIssuedBy($ordered[$i], $ordered[$i + 1])) { |
| 193 | return false; |
| 194 | } |
| 195 | } |
| 196 | |
| 197 | return true; |
| 198 | } |
| 199 | |
| 200 | /** |
| 201 | * Validate X.509 extensions in certificate chain |
| 202 | */ |
| 203 | public function validateChainExtensions(array $certificates): array { |
| 204 | $results = [ |
| 205 | 'valid' => true, |
| 206 | 'errors' => [], |
| 207 | 'certificates' => [] |
| 208 | ]; |
| 209 | |
| 210 | foreach ($certificates as $index => $cert) { |
| 211 | $certResult = $this->validateCertificateExtensions($cert, $certificates); |
| 212 | $results['certificates'][$index] = $certResult; |
| 213 | |
| 214 | if (!$certResult['valid']) { |
| 215 | $results['valid'] = false; |
| 216 | $results['errors'] = array_merge($results['errors'], $certResult['errors']); |
| 217 | } |
| 218 | } |
| 219 | |
| 220 | return $results; |
| 221 | } |
| 222 | |
| 223 | private function validateCertificateExtensions(array $cert, array $chain): array { |
| 224 | $result = [ |
| 225 | 'valid' => true, |
| 226 | 'errors' => [], |
| 227 | 'extensions' => [] |
| 228 | ]; |
| 229 | |
| 230 | if (!isset($cert['name'])) { |
| 231 | $result['errors'][] = 'Certificate missing name field'; |
| 232 | $result['valid'] = false; |
| 233 | return $result; |
| 234 | } |
| 235 | |
| 236 | if (!file_exists($cert['name'])) { |
| 237 | $result['errors'][] = 'Certificate file not found: ' . $cert['name']; |
| 238 | $result['valid'] = false; |
| 239 | return $result; |
| 240 | } |
| 241 | |
| 242 | $certContent = file_get_contents($cert['name']); |
| 243 | if ($certContent === false) { |
| 244 | $result['errors'][] = 'Failed to read certificate file: ' . $cert['name']; |
| 245 | $result['valid'] = false; |
| 246 | return $result; |
| 247 | } |
| 248 | |
| 249 | $certData = openssl_x509_parse($certContent); |
| 250 | if (!$certData) { |
| 251 | $result['errors'][] = 'Failed to parse certificate: ' . $cert['name']; |
| 252 | $result['valid'] = false; |
| 253 | return $result; |
| 254 | } |
| 255 | |
| 256 | $this->validateBasicConstraints($certData, $result); |
| 257 | $this->validateKeyUsage($certData, $result); |
| 258 | $this->validateAuthorityKeyIdentifier($certData, $chain, $result); |
| 259 | $this->validateSubjectKeyIdentifier($certData, $result); |
| 260 | |
| 261 | return $result; |
| 262 | } |
| 263 | |
| 264 | private function validateBasicConstraints(array $certData, array &$result): void { |
| 265 | if (!isset($certData['extensions']['basicConstraints'])) { |
| 266 | $result['errors'][] = 'Missing basicConstraints extension'; |
| 267 | $result['valid'] = false; |
| 268 | return; |
| 269 | } |
| 270 | |
| 271 | $isCA = $this->isRootCertificate(['subject' => $certData['subject'], 'issuer' => $certData['issuer']]); |
| 272 | $basicConstraints = $certData['extensions']['basicConstraints']; |
| 273 | |
| 274 | if ($isCA && !str_contains($basicConstraints, 'CA:TRUE')) { |
| 275 | $result['errors'][] = 'CA certificate missing CA:TRUE in basicConstraints'; |
| 276 | $result['valid'] = false; |
| 277 | } elseif (!$isCA && !str_contains($basicConstraints, 'CA:FALSE')) { |
| 278 | $result['errors'][] = 'End-entity certificate missing CA:FALSE in basicConstraints'; |
| 279 | $result['valid'] = false; |
| 280 | } |
| 281 | |
| 282 | $result['extensions']['basicConstraints'] = $basicConstraints; |
| 283 | } |
| 284 | |
| 285 | private function validateKeyUsage(array $certData, array &$result): void { |
| 286 | if (!isset($certData['extensions']['keyUsage'])) { |
| 287 | $result['errors'][] = 'Missing keyUsage extension'; |
| 288 | $result['valid'] = false; |
| 289 | return; |
| 290 | } |
| 291 | |
| 292 | $keyUsage = $certData['extensions']['keyUsage']; |
| 293 | $isCA = $this->isRootCertificate(['subject' => $certData['subject'], 'issuer' => $certData['issuer']]); |
| 294 | |
| 295 | if ($isCA) { |
| 296 | if (!str_contains($keyUsage, 'Certificate Sign')) { |
| 297 | $result['errors'][] = 'CA certificate missing Certificate Sign in keyUsage'; |
| 298 | $result['valid'] = false; |
| 299 | } |
| 300 | } else { |
| 301 | $requiredUsages = ['Digital Signature', 'Key Encipherment', 'Non Repudiation']; |
| 302 | foreach ($requiredUsages as $usage) { |
| 303 | if (!str_contains($keyUsage, $usage)) { |
| 304 | $result['errors'][] = "End-entity certificate missing {$usage} in keyUsage"; |
| 305 | $result['valid'] = false; |
| 306 | } |
| 307 | } |
| 308 | } |
| 309 | |
| 310 | $result['extensions']['keyUsage'] = $keyUsage; |
| 311 | } |
| 312 | |
| 313 | private function validateAuthorityKeyIdentifier(array $certData, array $chain, array &$result): void { |
| 314 | $isRoot = $this->isRootCertificate(['subject' => $certData['subject'], 'issuer' => $certData['issuer']]); |
| 315 | |
| 316 | if (!isset($certData['extensions']['authorityKeyIdentifier'])) { |
| 317 | if (!$isRoot) { |
| 318 | $result['errors'][] = 'Non-root certificate missing authorityKeyIdentifier'; |
| 319 | $result['valid'] = false; |
| 320 | } |
| 321 | return; |
| 322 | } |
| 323 | |
| 324 | $aki = $certData['extensions']['authorityKeyIdentifier']; |
| 325 | $result['extensions']['authorityKeyIdentifier'] = $aki; |
| 326 | |
| 327 | if (!$isRoot) { |
| 328 | if (!str_contains($aki, 'keyid:')) { |
| 329 | $result['errors'][] = 'authorityKeyIdentifier missing keyid for non-root certificate'; |
| 330 | $result['valid'] = false; |
| 331 | } |
| 332 | |
| 333 | $this->validateAkiMatchesIssuerSki($certData, $chain, $result); |
| 334 | } |
| 335 | } |
| 336 | |
| 337 | private function validateSubjectKeyIdentifier(array $certData, array &$result): void { |
| 338 | if (!isset($certData['extensions']['subjectKeyIdentifier'])) { |
| 339 | $result['errors'][] = 'Missing subjectKeyIdentifier extension'; |
| 340 | $result['valid'] = false; |
| 341 | return; |
| 342 | } |
| 343 | |
| 344 | $ski = $certData['extensions']['subjectKeyIdentifier']; |
| 345 | $result['extensions']['subjectKeyIdentifier'] = $ski; |
| 346 | |
| 347 | if (!preg_match('/^[0-9A-F:]+$/', $ski)) { |
| 348 | $result['errors'][] = 'Invalid subjectKeyIdentifier format'; |
| 349 | $result['valid'] = false; |
| 350 | } |
| 351 | } |
| 352 | |
| 353 | private function validateAkiMatchesIssuerSki(array $certData, array $chain, array &$result): void { |
| 354 | $issuerDN = $this->normalizeDistinguishedName((array)$certData['issuer']); |
| 355 | |
| 356 | foreach ($chain as $chainCert) { |
| 357 | if (!isset($chainCert['name'])) { |
| 358 | continue; |
| 359 | } |
| 360 | |
| 361 | $chainCertData = openssl_x509_parse(file_get_contents($chainCert['name'])); |
| 362 | if (!$chainCertData) { |
| 363 | continue; |
| 364 | } |
| 365 | |
| 366 | $chainSubjectDN = $this->normalizeDistinguishedName((array)$chainCertData['subject']); |
| 367 | |
| 368 | if ($issuerDN === $chainSubjectDN) { |
| 369 | if (isset($chainCertData['extensions']['subjectKeyIdentifier'])) { |
| 370 | $issuerSki = $chainCertData['extensions']['subjectKeyIdentifier']; |
| 371 | $aki = $certData['extensions']['authorityKeyIdentifier']; |
| 372 | |
| 373 | if (!str_contains((string)$aki, $issuerSki)) { |
| 374 | $result['errors'][] = 'authorityKeyIdentifier does not match issuer subjectKeyIdentifier'; |
| 375 | $result['valid'] = false; |
| 376 | } |
| 377 | } |
| 378 | break; |
| 379 | } |
| 380 | } |
| 381 | } |
| 382 | } |