Code Coverage
 
Lines
Functions and Methods
Classes and Traits
Total
82.18% covered (warning)
82.18%
166 / 202
42.11% covered (danger)
42.11%
8 / 19
CRAP
0.00% covered (danger)
0.00%
0 / 1
OrderCertificatesTrait
82.18% covered (warning)
82.18%
166 / 202
42.11% covered (danger)
42.11%
8 / 19
148.17
0.00% covered (danger)
0.00%
0 / 1
 orderCertificates
100.00% covered (success)
100.00%
6 / 6
100.00% covered (success)
100.00%
1 / 1
3
 buildChain
94.74% covered (success)
94.74%
18 / 19
0.00% covered (danger)
0.00%
0 / 1
7.01
 ensureValidStructure
100.00% covered (success)
100.00%
7 / 7
100.00% covered (success)
100.00%
1 / 1
7
 findLeafCertificate
88.89% covered (warning)
88.89%
8 / 9
0.00% covered (danger)
0.00%
0 / 1
6.05
 isRootCertificate
100.00% covered (success)
100.00%
3 / 3
100.00% covered (success)
100.00%
1 / 1
4
 isIssuedBy
83.33% covered (warning)
83.33%
5 / 6
0.00% covered (danger)
0.00%
0 / 1
5.12
 validateAkiSkiMatch
77.78% covered (warning)
77.78%
14 / 18
0.00% covered (danger)
0.00%
0 / 1
12.33
 normalizeDistinguishedName
100.00% covered (success)
100.00%
2 / 2
100.00% covered (success)
100.00%
1 / 1
1
 validateCertificateChain
100.00% covered (success)
100.00%
6 / 6
100.00% covered (success)
100.00%
1 / 1
1
 isValidChain
100.00% covered (success)
100.00%
8 / 8
100.00% covered (success)
100.00%
1 / 1
9
 hasRootCertificate
100.00% covered (success)
100.00%
4 / 4
100.00% covered (success)
100.00%
1 / 1
3
 isCompleteChain
85.71% covered (warning)
85.71%
6 / 7
0.00% covered (danger)
0.00%
0 / 1
4.05
 validateChainExtensions
100.00% covered (success)
100.00%
12 / 12
100.00% covered (success)
100.00%
1 / 1
3
 validateCertificateExtensions
78.57% covered (warning)
78.57%
22 / 28
0.00% covered (danger)
0.00%
0 / 1
5.25
 validateBasicConstraints
46.15% covered (danger)
46.15%
6 / 13
0.00% covered (danger)
0.00%
0 / 1
11.62
 validateKeyUsage
68.75% covered (warning)
68.75%
11 / 16
0.00% covered (danger)
0.00%
0 / 1
7.10
 validateAuthorityKeyIdentifier
69.23% covered (warning)
69.23%
9 / 13
0.00% covered (danger)
0.00%
0 / 1
5.73
 validateSubjectKeyIdentifier
77.78% covered (warning)
77.78%
7 / 9
0.00% covered (danger)
0.00%
0 / 1
3.10
 validateAkiMatchesIssuerSki
75.00% covered (warning)
75.00%
12 / 16
0.00% covered (danger)
0.00%
0 / 1
7.77
1<?php
2
3declare(strict_types=1);
4/**
5 * SPDX-FileCopyrightText: 2025 LibreCode coop and contributors
6 * SPDX-License-Identifier: AGPL-3.0-or-later
7 */
8
9namespace OCA\Libresign\Handler\CertificateEngine;
10
11use InvalidArgumentException;
12
13trait 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}