Code Coverage
 
Lines
Functions and Methods
Classes and Traits
Total
92.24% covered (success)
92.24%
202 / 219
52.94% covered (warning)
52.94%
9 / 17
CRAP
0.00% covered (danger)
0.00%
0 / 1
CertificateSignersMergeService
92.24% covered (success)
92.24%
202 / 219
52.94% covered (warning)
52.94%
9 / 17
160.52
0.00% covered (danger)
0.00%
0 / 1
 merge
100.00% covered (success)
100.00%
39 / 39
100.00% covered (success)
100.00%
1 / 1
8
 isTechnicalTimestampEntry
75.00% covered (warning)
75.00%
6 / 8
0.00% covered (danger)
0.00%
0 / 1
9.00
 getSingleContractSignerIndex
100.00% covered (success)
100.00%
7 / 7
100.00% covered (success)
100.00%
1 / 1
6
 hydrateTimestampOnly
100.00% covered (success)
100.00%
3 / 3
100.00% covered (success)
100.00%
1 / 1
3
 resolveCertSignerUid
86.67% covered (warning)
86.67%
13 / 15
0.00% covered (danger)
0.00%
0 / 1
12.34
 resolveTargetIndex
100.00% covered (success)
100.00%
7 / 7
100.00% covered (success)
100.00%
1 / 1
4
 ensureSignerSlotExists
100.00% covered (success)
100.00%
2 / 2
100.00% covered (success)
100.00%
1 / 1
2
 hydrateSignerFromCertData
85.71% covered (warning)
85.71%
24 / 28
0.00% covered (danger)
0.00%
0 / 1
16.75
 assignSignerUid
75.00% covered (warning)
75.00%
6 / 8
0.00% covered (danger)
0.00%
0 / 1
5.39
 assignSignerDisplayName
100.00% covered (success)
100.00%
10 / 10
100.00% covered (success)
100.00%
1 / 1
7
 hasContractSigners
83.33% covered (warning)
83.33%
5 / 6
0.00% covered (danger)
0.00%
0 / 1
8.30
 buildSignerIndexMap
60.00% covered (warning)
60.00%
6 / 10
0.00% covered (danger)
0.00%
0 / 1
10.14
 findMatchingSignerIndex
100.00% covered (success)
100.00%
9 / 9
100.00% covered (success)
100.00%
1 / 1
5
 nextAvailableSignerIndex
75.00% covered (warning)
75.00%
3 / 4
0.00% covered (danger)
0.00%
0 / 1
3.14
 processChainData
100.00% covered (success)
100.00%
15 / 15
100.00% covered (success)
100.00%
1 / 1
11
 enrichSignerWithCertificateValidation
100.00% covered (success)
100.00%
28 / 28
100.00% covered (success)
100.00%
1 / 1
27
 tryMatchWithExistingSigners
95.00% covered (success)
95.00%
19 / 20
0.00% covered (danger)
0.00%
0 / 1
18
1<?php
2
3declare(strict_types=1);
4/**
5 * SPDX-FileCopyrightText: 2026 LibreCode coop and contributors
6 * SPDX-License-Identifier: AGPL-3.0-or-later
7 */
8
9namespace OCA\Libresign\Service\File;
10
11use DateTime;
12use DateTimeInterface;
13
14class CertificateSignersMergeService {
15    /**
16     * @param callable(array, string): (?string) $resolveUid
17     * @param callable(string, string): string $buildIdentifier
18     * @param callable(string): (?string) $lookupAccountDisplayName
19     */
20    public function merge(
21        \stdClass $fileData,
22        array $certData,
23        string $host,
24        string $signedStatusText,
25        callable $resolveUid,
26        callable $buildIdentifier,
27        callable $lookupAccountDisplayName,
28    ): void {
29        $existingSigners = $fileData->signers ?? [];
30        $hasContractSigners = $this->hasContractSigners($existingSigners);
31        $indexMap = $this->buildSignerIndexMap($existingSigners, $buildIdentifier);
32        $usedIndexes = [];
33        $lastMatchedSignerIndex = null;
34        $singleContractSignerIndex = $this->getSingleContractSignerIndex($existingSigners);
35
36        foreach ($certData as $index => $signer) {
37            $resolvedUid = $this->resolveCertSignerUid($signer, $existingSigners, $host, $resolveUid);
38            $matchedIndex = $this->findMatchingSignerIndex($indexMap, $resolvedUid, $signer);
39            $targetIndex = $this->resolveTargetIndex(
40                $matchedIndex,
41                $existingSigners,
42                $usedIndexes,
43                $hasContractSigners,
44                $index,
45            );
46            if ($targetIndex === null) {
47                $timestampTargetIndex = $lastMatchedSignerIndex ?? $singleContractSignerIndex;
48                if ($timestampTargetIndex !== null
49                    && isset($fileData->signers[$timestampTargetIndex])
50                    && $this->isTechnicalTimestampEntry($signer)
51                ) {
52                    $this->hydrateTimestampOnly($fileData->signers[$timestampTargetIndex], $signer);
53                }
54                continue;
55            }
56
57            $isLibreSignMatch = $matchedIndex !== null && isset($existingSigners[$matchedIndex]->signRequestId);
58            $usedIndexes[$targetIndex] = true;
59
60            $this->ensureSignerSlotExists($fileData, $targetIndex);
61            $this->hydrateSignerFromCertData(
62                $fileData->signers[$targetIndex],
63                $signer,
64                $resolvedUid,
65                $isLibreSignMatch,
66                $host,
67                $signedStatusText,
68                $resolveUid,
69                $lookupAccountDisplayName,
70            );
71
72            if (isset($fileData->signers[$targetIndex]->uid)) {
73                $indexMap[strtolower((string)$fileData->signers[$targetIndex]->uid)] = $targetIndex;
74            }
75
76            $lastMatchedSignerIndex = $targetIndex;
77        }
78    }
79
80    private function isTechnicalTimestampEntry(array $signer): bool {
81        if (!isset($signer['timestamp']) || !is_array($signer['timestamp'])) {
82            return false;
83        }
84
85        if (isset($signer['uid']) && is_string($signer['uid']) && $signer['uid'] !== '') {
86            return false;
87        }
88
89        $subjectUid = $signer['chain'][0]['subject']['UID'] ?? null;
90        if (is_string($subjectUid) && $subjectUid !== '') {
91            return false;
92        }
93
94        return true;
95    }
96
97    private function getSingleContractSignerIndex(array $signers): ?int {
98        $contractIndexes = [];
99        foreach ($signers as $index => $signer) {
100            if (is_object($signer) && isset($signer->signRequestId) && is_numeric($signer->signRequestId)) {
101                $contractIndexes[] = $index;
102            }
103        }
104
105        if (count($contractIndexes) === 1) {
106            return $contractIndexes[0];
107        }
108
109        return null;
110    }
111
112    private function hydrateTimestampOnly(\stdClass $targetSigner, array $signer): void {
113        $targetSigner->timestamp = $signer['timestamp'];
114        if (isset($signer['timestamp']['genTime']) && $signer['timestamp']['genTime'] instanceof DateTimeInterface) {
115            $targetSigner->timestamp['genTime'] = $signer['timestamp']['genTime']->format(DateTimeInterface::ATOM);
116        }
117    }
118
119    /**
120     * @param callable(array, string): (?string) $resolveUid
121     */
122    private function resolveCertSignerUid(array $signer, array $existingSigners, string $host, callable $resolveUid): ?string {
123        if (!isset($signer['chain'][0]) || !is_array($signer['chain'][0])) {
124            return is_string($signer['uid'] ?? null) ? $signer['uid'] : null;
125        }
126
127        $resolvedUid = $this->tryMatchWithExistingSigners($signer['chain'][0], $existingSigners, $host, $resolveUid);
128        if ($resolvedUid) {
129            return $resolvedUid;
130        }
131
132        $isLibreSignCert = isset($signer['chain'][0]['isLibreSignRootCA'])
133            && $signer['chain'][0]['isLibreSignRootCA'] === true;
134        if ($isLibreSignCert) {
135            $certUid = $signer['chain'][0]['subject']['UID'] ?? null;
136            if (!is_string($certUid) || $certUid === '') {
137                return null;
138            }
139            return str_contains($certUid, ':') ? $certUid : 'account:' . $certUid;
140        }
141
142        if (is_string($signer['uid'] ?? null) && $signer['uid'] !== '') {
143            return $signer['uid'];
144        }
145
146        return $resolveUid($signer['chain'][0], $host);
147    }
148
149    private function resolveTargetIndex(
150        ?int $matchedIndex,
151        array $existingSigners,
152        array $usedIndexes,
153        bool $hasContractSigners,
154        int $defaultIndex,
155    ): ?int {
156        if ($matchedIndex !== null) {
157            return $matchedIndex;
158        }
159
160        if ($hasContractSigners) {
161            return null;
162        }
163
164        if (empty($existingSigners)) {
165            return $defaultIndex;
166        }
167
168        return $this->nextAvailableSignerIndex($existingSigners, $usedIndexes);
169    }
170
171    private function ensureSignerSlotExists(\stdClass $fileData, int $targetIndex): void {
172        if (!isset($fileData->signers[$targetIndex])) {
173            $fileData->signers[$targetIndex] = new \stdClass();
174        }
175    }
176
177    /**
178     * @param callable(array, string): (?string) $resolveUid
179     * @param callable(string): (?string) $lookupAccountDisplayName
180     */
181    private function hydrateSignerFromCertData(
182        \stdClass $targetSigner,
183        array $signer,
184        ?string $resolvedUid,
185        bool $isLibreSignMatch,
186        string $host,
187        string $signedStatusText,
188        callable $resolveUid,
189        callable $lookupAccountDisplayName,
190    ): void {
191        $preservedDisplayName = $isLibreSignMatch && isset($targetSigner->displayName)
192            ? $targetSigner->displayName
193            : null;
194
195        $targetSigner->status = 2;
196        $targetSigner->statusText = $signedStatusText;
197
198        if (isset($signer['timestamp'])) {
199            $targetSigner->timestamp = $signer['timestamp'];
200            if (isset($signer['timestamp']['genTime']) && $signer['timestamp']['genTime'] instanceof DateTimeInterface) {
201                $targetSigner->timestamp['genTime'] = $signer['timestamp']['genTime']->format(DateTimeInterface::ATOM);
202            }
203        }
204        if (isset($signer['signingTime']) && $signer['signingTime'] instanceof DateTimeInterface) {
205            $targetSigner->signingTime = $signer['signingTime'];
206            $targetSigner->signed = $signer['signingTime']->format(DateTimeInterface::ATOM);
207        }
208        if (isset($signer['docmdp'])) {
209            $targetSigner->docmdp = $signer['docmdp'];
210        }
211        if (isset($signer['docmdp_validation'])) {
212            $targetSigner->docmdp_validation = $signer['docmdp_validation'];
213        }
214        if (isset($signer['modifications'])) {
215            $targetSigner->modifications = $signer['modifications'];
216        }
217        if (isset($signer['modification_validation'])) {
218            $targetSigner->modification_validation = $signer['modification_validation'];
219        }
220
221        if (isset($signer['chain']) && is_array($signer['chain'])) {
222            $this->processChainData($targetSigner, $signer['chain']);
223        }
224
225        $this->assignSignerUid($targetSigner, $signer, $resolvedUid, $host, $resolveUid);
226
227        if (isset($signer['signDate'])) {
228            $targetSigner->signDate = $signer['signDate'];
229        }
230        if (isset($signer['type'])) {
231            $targetSigner->type = $signer['type'];
232        }
233
234        $this->assignSignerDisplayName($targetSigner, $signer, $preservedDisplayName, $lookupAccountDisplayName);
235    }
236
237    /**
238     * @param callable(array, string): (?string) $resolveUid
239     */
240    private function assignSignerUid(\stdClass $targetSigner, array $signer, ?string $resolvedUid, string $host, callable $resolveUid): void {
241        if (isset($signer['uid'])) {
242            $targetSigner->uid = $signer['uid'];
243            return;
244        }
245
246        if ($resolvedUid) {
247            $targetSigner->uid = $resolvedUid;
248            return;
249        }
250
251        if (isset($signer['chain'][0]) && is_array($signer['chain'][0])) {
252            $targetSigner->uid = $resolveUid($signer['chain'][0], $host);
253        }
254    }
255
256    /**
257     * @param callable(string): (?string) $lookupAccountDisplayName
258     */
259    private function assignSignerDisplayName(\stdClass $targetSigner, array $signer, ?string $preservedDisplayName, callable $lookupAccountDisplayName): void {
260        if ($preservedDisplayName) {
261            $targetSigner->displayName = $preservedDisplayName;
262            return;
263        }
264
265        if (isset($targetSigner->uid) && str_starts_with($targetSigner->uid, 'account:')) {
266            $accountId = substr($targetSigner->uid, strlen('account:'));
267            $displayName = $lookupAccountDisplayName($accountId);
268            $targetSigner->displayName = $displayName ?: $accountId;
269            return;
270        }
271
272        if (!isset($targetSigner->displayName) && isset($signer['chain'][0])) {
273            $targetSigner->displayName = $signer['chain'][0]['name'] ?? ($signer['chain'][0]['subject']['CN'] ?? '');
274        }
275    }
276
277    private function hasContractSigners(array $signers): bool {
278        foreach ($signers as $signer) {
279            if (is_object($signer) && isset($signer->signRequestId) && is_numeric($signer->signRequestId)) {
280                return true;
281            }
282            if (is_array($signer) && isset($signer['signRequestId']) && is_numeric($signer['signRequestId'])) {
283                return true;
284            }
285        }
286
287        return false;
288    }
289
290    /**
291     * @param callable(string, string): string $buildIdentifier
292     */
293    private function buildSignerIndexMap(array $signers, callable $buildIdentifier): array {
294        $map = [];
295        foreach ($signers as $index => $signer) {
296            if (isset($signer->uid)) {
297                $map[strtolower((string)$signer->uid)] = $index;
298            }
299            if (!empty($signer->identifyMethods)) {
300                foreach ($signer->identifyMethods as $identifyMethod) {
301                    if (isset($identifyMethod['method']) && isset($identifyMethod['value'])) {
302                        $identifier = $buildIdentifier($identifyMethod['method'], $identifyMethod['value']);
303                        $map[strtolower($identifier)] = $index;
304                    }
305                }
306            }
307        }
308        return $map;
309    }
310
311    private function findMatchingSignerIndex(array $indexMap, ?string $resolvedUid, array $certSigner): ?int {
312        $identifiers = [];
313        if ($resolvedUid) {
314            $identifiers[] = strtolower($resolvedUid);
315        }
316        if (!empty($certSigner['uid'])) {
317            $identifiers[] = strtolower((string)$certSigner['uid']);
318        }
319        foreach ($identifiers as $identifier) {
320            if (isset($indexMap[$identifier])) {
321                return $indexMap[$identifier];
322            }
323        }
324        return null;
325    }
326
327    private function nextAvailableSignerIndex(array $existingSigners, array $usedIndexes): int {
328        $index = count($existingSigners);
329        while (isset($existingSigners[$index]) || isset($usedIndexes[$index])) {
330            $index++;
331        }
332        return $index;
333    }
334
335    private function processChainData(\stdClass $signer, array $chain): void {
336        $signer->chain = [];
337
338        foreach ($chain as $chainIndex => $chainItem) {
339            $chainArr = $chainItem;
340
341            if (isset($chainItem['validFrom_time_t']) && is_numeric($chainItem['validFrom_time_t'])) {
342                $chainArr['valid_from'] = (new DateTime('@' . $chainItem['validFrom_time_t'], new \DateTimeZone('UTC')))->format(DateTimeInterface::ATOM);
343            }
344            if (isset($chainItem['validTo_time_t']) && is_numeric($chainItem['validTo_time_t'])) {
345                $chainArr['valid_to'] = (new DateTime('@' . $chainItem['validTo_time_t'], new \DateTimeZone('UTC')))->format(DateTimeInterface::ATOM);
346            }
347
348            $chainArr['displayName'] = $chainArr['name'] ?? ($chainArr['subject']['CN'] ?? '');
349            $signer->chain[$chainIndex] = $chainArr;
350        }
351
352        if (isset($chain[0])) {
353            $this->enrichSignerWithCertificateValidation($signer, $chain[0]);
354        }
355
356        // Promote the end-entity (leaf) certificate validity dates to the signer root so the
357        // validation UI and the SignerDetail API contract can read them from a stable location.
358        // The chain still carries the full per-certificate dates for the certificate-chain view.
359        if (isset($signer->chain[0]['valid_from']) && !isset($signer->valid_from)) {
360            $signer->valid_from = $signer->chain[0]['valid_from'];
361        }
362        if (isset($signer->chain[0]['valid_to']) && !isset($signer->valid_to)) {
363            $signer->valid_to = $signer->chain[0]['valid_to'];
364        }
365    }
366
367    private function enrichSignerWithCertificateValidation(\stdClass $signer, array $endEntityCert): void {
368        if (isset($endEntityCert['name']) && !isset($signer->name)) {
369            $signer->name = $endEntityCert['name'];
370        }
371        if (isset($endEntityCert['hash']) && !isset($signer->hash)) {
372            $signer->hash = $endEntityCert['hash'];
373        }
374        if (isset($endEntityCert['serialNumber']) && !isset($signer->serialNumber)) {
375            $signer->serialNumber = $endEntityCert['serialNumber'];
376        }
377        if (isset($endEntityCert['serialNumberHex']) && !isset($signer->serialNumberHex)) {
378            $signer->serialNumberHex = $endEntityCert['serialNumberHex'];
379        }
380        if (isset($endEntityCert['signatureTypeSN']) && !isset($signer->signatureTypeSN)) {
381            $signer->signatureTypeSN = $endEntityCert['signatureTypeSN'];
382        }
383
384        if (isset($endEntityCert['subject']) && !isset($signer->subject)) {
385            $signer->subject = $endEntityCert['subject'];
386        }
387
388        if (isset($endEntityCert['crl_urls']) && !isset($signer->crl_urls)) {
389            $signer->crl_urls = $endEntityCert['crl_urls'];
390        }
391        if (isset($endEntityCert['crl_validation']) && !isset($signer->crl_validation)) {
392            $signer->crl_validation = $endEntityCert['crl_validation'];
393        }
394        if (isset($endEntityCert['crl_revoked_at']) && !isset($signer->crl_revoked_at)) {
395            $signer->crl_revoked_at = $endEntityCert['crl_revoked_at'];
396        }
397
398        if (isset($endEntityCert['signature_validation']) && !isset($signer->signature_validation)) {
399            $signer->signature_validation = $endEntityCert['signature_validation'];
400        }
401
402        if (isset($endEntityCert['isLibreSignRootCA']) && !isset($signer->isLibreSignRootCA)) {
403            $signer->isLibreSignRootCA = $endEntityCert['isLibreSignRootCA'];
404        }
405
406        if (isset($endEntityCert['covers_entire_document']) && !isset($signer->covers_entire_document)) {
407            $signer->covers_entire_document = $endEntityCert['covers_entire_document'];
408        }
409
410        if (
411            isset($endEntityCert['document_modification_state'])
412            && !isset($signer->document_modification_state)
413        ) {
414            $signer->document_modification_state
415                = $endEntityCert['document_modification_state'];
416        }
417    }
418
419    /**
420     * @param callable(array, string): (?string) $resolveUid
421     */
422    private function tryMatchWithExistingSigners(array $certData, array $existingSigners, string $host, callable $resolveUid): ?string {
423        if (empty($existingSigners)) {
424            return null;
425        }
426
427        $certSerialNumber = $certData['serialNumber'] ?? null;
428        $certSerialNumberHex = $certData['serialNumberHex'] ?? null;
429        $certHash = $certData['hash'] ?? null;
430
431        if (!$certSerialNumber && !$certSerialNumberHex && !$certHash) {
432            return null;
433        }
434
435        foreach ($existingSigners as $signer) {
436            if (!isset($signer->metadata) || !is_array($signer->metadata)) {
437                continue;
438            }
439
440            $certInfo = $signer->metadata['certificate_info'] ?? null;
441            if (!is_array($certInfo)) {
442                continue;
443            }
444
445            if ($certSerialNumber && isset($certInfo['serialNumber']) && $certSerialNumber === $certInfo['serialNumber']) {
446                return $signer->uid ?? $resolveUid($certData, $host);
447            }
448
449            if ($certSerialNumberHex && isset($certInfo['serialNumberHex']) && $certSerialNumberHex === $certInfo['serialNumberHex']) {
450                return $signer->uid ?? $resolveUid($certData, $host);
451            }
452
453            if ($certHash && isset($certInfo['hash']) && $certHash === $certInfo['hash']) {
454                return $signer->uid ?? $resolveUid($certData, $host);
455            }
456        }
457
458        return null;
459    }
460}