Code Coverage
 
Lines
Functions and Methods
Classes and Traits
Total
87.11% covered (warning)
87.11%
169 / 194
38.89% covered (danger)
38.89%
7 / 18
CRAP
0.00% covered (danger)
0.00%
0 / 1
Pkcs12Handler
87.11% covered (warning)
87.11%
169 / 194
38.89% covered (danger)
38.89%
7 / 18
81.79
0.00% covered (danger)
0.00%
0 / 1
 __construct
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 getCertificateEngineFactory
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 setIsLibreSignFile
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 setPolicyUserIdForValidation
75.00% covered (warning)
75.00%
3 / 4
0.00% covered (danger)
0.00%
0 / 1
3.14
 getCertificateChain
96.00% covered (success)
96.00%
24 / 25
0.00% covered (danger)
0.00%
0 / 1
5
 processSignature
96.43% covered (success)
96.43%
27 / 28
0.00% covered (danger)
0.00%
0 / 1
7
 applyLibreSignRootCAFlag
63.64% covered (warning)
63.64%
7 / 11
0.00% covered (danger)
0.00%
0 / 1
7.73
 extractDocMdpData
100.00% covered (success)
100.00%
4 / 4
100.00% covered (success)
100.00%
1 / 1
2
 extractSigningTime
100.00% covered (success)
100.00%
7 / 7
100.00% covered (success)
100.00%
1 / 1
3
 mapTimestampToken
100.00% covered (success)
100.00%
13 / 13
100.00% covered (success)
100.00%
1 / 1
3
 extractCertificateChain
76.19% covered (warning)
76.19%
16 / 21
0.00% covered (danger)
0.00%
0 / 1
10.09
 isLibreSignRootCA
81.82% covered (warning)
81.82%
9 / 11
0.00% covered (danger)
0.00%
0 / 1
5.15
 hasLibreSignCaId
90.91% covered (success)
90.91%
10 / 11
0.00% covered (danger)
0.00%
0 / 1
6.03
 getRootCertificatePem
84.62% covered (warning)
84.62%
11 / 13
0.00% covered (danger)
0.00%
0 / 1
6.13
 enrichLeafWithNativeData
79.17% covered (warning)
79.17%
19 / 24
0.00% covered (danger)
0.00%
0 / 1
8.58
 getHandler
87.50% covered (warning)
87.50%
7 / 8
0.00% covered (danger)
0.00%
0 / 1
3.02
 sign
80.00% covered (warning)
80.00%
8 / 10
0.00% covered (danger)
0.00%
0 / 1
1.01
 isHandlerOk
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
1<?php
2
3declare(strict_types=1);
4/**
5 * SPDX-FileCopyrightText: 2020-2024 LibreCode coop and contributors
6 * SPDX-License-Identifier: AGPL-3.0-or-later
7 */
8
9namespace OCA\Libresign\Handler\SignEngine;
10
11use OCA\Libresign\AppInfo\Application;
12use OCA\Libresign\Exception\LibresignException;
13use OCA\Libresign\Handler\CertificateEngine\CertificateEngineFactory;
14use OCA\Libresign\Handler\CertificateEngine\OrderCertificatesTrait;
15use OCA\Libresign\Handler\DocMdpHandler;
16use OCA\Libresign\Handler\FooterHandler;
17use OCA\Libresign\Handler\SignEngine\JSignPdf\JSignPdfHandler;
18use OCA\Libresign\Service\CaIdentifierService;
19use OCA\Libresign\Service\Crl\CrlService;
20use OCA\Libresign\Service\FolderService;
21use OCA\Libresign\Service\Signature\PdfSignatureValidationService;
22use OCA\Libresign\Vendor\LibreSign\PdfSignatureValidator\Model\ExtractedSignature;
23use OCA\Libresign\Vendor\LibreSign\PdfSignatureValidator\Model\TimestampToken;
24use OCA\Libresign\Vendor\phpseclib4\Exception\UnexpectedValueException;
25use OCA\Libresign\Vendor\phpseclib4\File\ASN1;
26use OCP\Files\File;
27use OCP\IAppConfig;
28use OCP\IL10N;
29use Psr\Log\LoggerInterface;
30
31class Pkcs12Handler extends SignEngineHandler {
32    use OrderCertificatesTrait;
33    protected string $certificate = '';
34    /** @var array<string, class-string<SignEngineHandler>> */
35    private const ENGINE_HANDLERS = [
36        'jSignPdfHandler' => JSignPdfHandler::class,
37        'phpNativeHandler' => PhpNativeHandler::class,
38    ];
39    private ?JSignPdfHandler $jSignPdfHandler = null;
40    private ?PhpNativeHandler $phpNativeHandler = null;
41    private string $rootCertificatePem = '';
42    private bool $isLibreSignFile = false;
43    private ?string $policyUserIdForValidation = null;
44
45    public function __construct(
46        private FolderService $folderService,
47        private IAppConfig $appConfig,
48        protected CertificateEngineFactory $certificateEngineFactory,
49        private IL10N $l10n,
50        private FooterHandler $footerHandler,
51        private LoggerInterface $logger,
52        private CaIdentifierService $caIdentifierService,
53        private DocMdpHandler $docMdpHandler,
54        private CrlService $crlService,
55        private PdfSignatureValidationService $pdfSignatureValidationService,
56    ) {
57        parent::__construct($l10n, $folderService, $logger);
58    }
59
60    #[\Override]
61    protected function getCertificateEngineFactory(): CertificateEngineFactory {
62        return $this->certificateEngineFactory;
63    }
64
65    public function setIsLibreSignFile(bool $isLibreSignFile = true): void {
66        $this->isLibreSignFile = $isLibreSignFile;
67    }
68
69    public function setPolicyUserIdForValidation(?string $userId): self {
70        $this->policyUserIdForValidation = is_string($userId) && trim($userId) !== ''
71            ? trim($userId)
72            : null;
73
74        return $this;
75    }
76
77    /**
78     * @param resource $resource
79     * @throws LibresignException When is not a signed file
80     * @return array
81     */
82    #[\Override]
83    public function getCertificateChain($resource): array {
84        $certificates = [];
85        $certificateEngine = $this->getCertificateEngine();
86        $certificateEngine->setPolicyUserIdForValidation($this->policyUserIdForValidation);
87
88        try {
89            rewind($resource);
90            $validationResults = array_values(
91                $this->pdfSignatureValidationService->validateFromResource($resource)
92            );
93
94            if ($validationResults === []) {
95                throw new LibresignException($this->l10n->t('Unsigned file.'));
96            }
97
98            foreach ($validationResults as $validation) {
99                $signature = $validation['signature'] ?? null;
100                if (!$signature instanceof ExtractedSignature) {
101                    continue;
102                }
103
104                $result = $this->processSignature(
105                    $resource,
106                    $signature,
107                    $validation,
108                );
109
110                if (empty($result['chain'])) {
111                    continue;
112                }
113
114                $certificates[] = $result;
115            }
116        } finally {
117            $certificateEngine->setPolicyUserIdForValidation(null);
118            $this->policyUserIdForValidation = null;
119            $this->isLibreSignFile = false;
120        }
121
122        return $certificates;
123    }
124
125    private function processSignature(
126        $resource,
127        ExtractedSignature $signature,
128        array $validation = [],
129    ): array {
130        $binarySignature = $signature->binarySignature;
131        if ($binarySignature === null || $binarySignature === '') {
132            return $this->enrichLeafWithNativeData(
133                ['chain' => [[]]],
134                $signature,
135                $validation,
136            );
137        }
138
139        $result = [];
140
141        try {
142            $decoded = ASN1::decodeBER($binarySignature);
143        } catch (UnexpectedValueException) {
144            $decoded = null;
145        }
146
147        $result = $this->extractSigningTime($decoded, $result);
148
149        $timestamp = $validation['timestamp'] ?? null;
150        if ($timestamp instanceof TimestampToken) {
151            $result['timestamp'] = $this->mapTimestampToken($timestamp);
152        }
153
154        $pemCertificates = $validation['certificates'] ?? [];
155        if (!is_array($pemCertificates)) {
156            $pemCertificates = [];
157        }
158
159        $chain = $this->extractCertificateChain($pemCertificates);
160        if (!empty($chain)) {
161            $result['chain'] = $this->orderCertificates($chain);
162            $result = $this->enrichLeafWithNativeData(
163                $result,
164                $signature,
165                $validation,
166            );
167        }
168
169        $result = $this->extractDocMdpData($resource, $result);
170
171        return $this->applyLibreSignRootCAFlag($result);
172    }
173
174    private function applyLibreSignRootCAFlag(array $signer): array {
175        if (empty($signer['chain'])) {
176            return $signer;
177        }
178
179        foreach ($signer['chain'] as $key => $cert) {
180            if ($cert['isLibreSignRootCA']
181                && isset($cert['certificate_validation'])
182                && $cert['certificate_validation']['id'] !== 1
183            ) {
184                $signer['chain'][$key]['certificate_validation'] = [
185                    'id' => 1,
186                    // TRANSLATORS Status label on LibreSign signature validation when the signer certificate chains to LibreSign's own root CA and is accepted.
187                    'label' => $this->l10n->t('Certificate is trusted.'),
188                ];
189            }
190        }
191
192        return $signer;
193    }
194
195    private function extractDocMdpData($resource, array $result): array {
196        if (empty($result['chain'])) {
197            return $result;
198        }
199
200        $docMdpData = $this->docMdpHandler->extractDocMdpData($resource);
201        return array_merge($result, $docMdpData);
202    }
203
204    private function extractSigningTime(?array $decoded, array $result): array {
205        if ($decoded === null) {
206            return $result;
207        }
208
209        $tsa = new TSA();
210        $signingTime = $tsa->getSigninTime($decoded);
211        if ($signingTime instanceof \DateTime) {
212            $result['signingTime'] = $signingTime;
213        }
214
215        return $result;
216    }
217
218    private function mapTimestampToken(TimestampToken $timestamp): array {
219        $result = [
220            'genTime' => $timestamp->generatedAt,
221            'policy' => $timestamp->policyOid,
222            'serialNumber' => $timestamp->serialNumber,
223            'cnHints' => $timestamp->certificateSubject,
224            'tsaName' => $timestamp->certificateSubject['commonName'] ?? null,
225        ];
226
227        return array_filter(
228            $result,
229            static fn (mixed $value): bool => $value !== null
230                && $value !== ''
231                && $value !== [],
232        );
233    }
234
235    /**
236     * @param list<string> $pemCertificates
237     */
238    private function extractCertificateChain(array $pemCertificates): array {
239        $chain = [];
240        $isLibreSignRootCA = false;
241        $certificateEngine = $this->getCertificateEngine();
242
243        foreach ($pemCertificates as $index => $pemCertificate) {
244            if (!is_string($pemCertificate) || $pemCertificate === '') {
245                continue;
246            }
247
248            $parsed = $certificateEngine->parseCertificate($pemCertificate);
249            if (!$parsed) {
250                continue;
251            }
252
253            if (!$isLibreSignRootCA) {
254                $isLibreSignRootCA = $this->isLibreSignRootCA(
255                    $pemCertificate,
256                    $parsed,
257                );
258            }
259
260            $parsed['isLibreSignRootCA'] = $isLibreSignRootCA;
261            $chain[$index] = $parsed;
262        }
263
264        if ($isLibreSignRootCA || $this->isLibreSignFile) {
265            foreach ($chain as &$cert) {
266                $cert['isLibreSignRootCA'] = true;
267            }
268            unset($cert);
269        }
270
271        return $chain;
272    }
273
274    private function isLibreSignRootCA(string $certificate, array $parsed): bool {
275        $crlUrls = $parsed['crl_urls'] ?? [];
276        $rootCertificatePem = is_array($crlUrls) ? $this->crlService->getRootCertificateFromCrlUrls($crlUrls) : '';
277
278        if (empty($rootCertificatePem)) {
279            $rootCertificatePem = $this->getRootCertificatePem();
280        }
281
282        if (empty($rootCertificatePem)) {
283            return false;
284        }
285
286        $rootFingerprint = openssl_x509_fingerprint($rootCertificatePem, 'sha256');
287        $fingerprint = openssl_x509_fingerprint($certificate, 'sha256');
288        if ($rootFingerprint === $fingerprint) {
289            return true;
290        }
291
292        return $this->hasLibreSignCaId($parsed);
293    }
294
295    private function hasLibreSignCaId(array $parsed): bool {
296        $instanceId = $this->appConfig->getValueString(Application::APP_ID, 'instance_id', '');
297        if (strlen($instanceId) !== 10 || !isset($parsed['subject']['OU'])) {
298            return false;
299        }
300
301        $organizationalUnits = $parsed['subject']['OU'];
302        if (is_string($organizationalUnits)) {
303            $organizationalUnits = [$organizationalUnits];
304        }
305
306        foreach ($organizationalUnits as $ou) {
307            $ou = trim((string)$ou);
308            if ($this->caIdentifierService->isValidCaId($ou, $instanceId)) {
309                return true;
310            }
311        }
312
313        return false;
314    }
315
316    private function getRootCertificatePem(): string {
317        if (!empty($this->rootCertificatePem)) {
318            return $this->rootCertificatePem;
319        }
320        $configPath = $this->appConfig->getValueString(Application::APP_ID, 'config_path');
321        $caPemPath = $configPath . DIRECTORY_SEPARATOR . 'ca.pem';
322
323        if (empty($configPath)
324            || !is_dir($configPath)
325            || !is_readable($caPemPath)
326        ) {
327            return '';
328        }
329
330        $rootCertificatePem = file_get_contents($caPemPath);
331        if ($rootCertificatePem === false) {
332            return '';
333        }
334        $this->rootCertificatePem = $rootCertificatePem;
335        return $this->rootCertificatePem;
336    }
337
338    private function enrichLeafWithNativeData(
339        array $result,
340        ExtractedSignature $signature,
341        array $validation,
342    ): array {
343        if (empty($result['chain'])) {
344            return $result;
345        }
346
347        $leaf = &$result['chain'][0];
348        $metadata = $signature->metadata;
349
350        $leaf['field'] = $metadata->field;
351        $leaf['range'] = $metadata->range;
352        $leaf['signature_type'] = $metadata->signatureType;
353        $leaf['signing_hash_algorithm'] = $signature->hashAlgorithm;
354        $leaf['covers_entire_document'] = $metadata->coversEntireDocument;
355
356        if ($metadata->documentModificationState !== null) {
357            $leaf['document_modification_state']
358                = $metadata->documentModificationState->value;
359        }
360
361        if (
362            isset($validation['signatureValidation'])
363            && is_array($validation['signatureValidation'])
364        ) {
365            $leaf['signature_validation'] = $validation['signatureValidation'];
366        }
367
368        if (
369            isset($validation['certificateValidation'])
370            && is_array($validation['certificateValidation'])
371        ) {
372            $leaf['certificate_validation'] = $validation['certificateValidation'];
373        }
374
375        if (!isset($leaf['certificate_validation'])) {
376            $leaf['certificate_validation'] = [
377                'id' => 3,
378                // TRANSLATORS Status label on LibreSign signature validation when the signer certificate's issuing CA is not in the trusted store.
379                'label' => $this->l10n->t('Certificate issuer is unknown.'),
380            ];
381        }
382
383        return $result;
384    }
385
386    private function getHandler(): SignEngineHandler {
387        $sign_engine = $this->appConfig->getValueString(Application::APP_ID, 'signature_engine', 'JSignPdf');
388        $property = lcfirst($sign_engine) . 'Handler';
389        // Resolved through a class map instead of a name built at runtime, so
390        // moving a handler to another namespace cannot break this silently.
391        if (!isset(self::ENGINE_HANDLERS[$property])) {
392            // TRANSLATORS API/config error when LibreSign's signature_engine setting names a backend that is not available (for example a mistyped JSignPdf/native engine).
393            throw new LibresignException($this->l10n->t('Invalid Sign engine.'), 400);
394        }
395        $classHandler = self::ENGINE_HANDLERS[$property];
396        if (!$this->$property instanceof $classHandler) {
397            $this->$property = \OCP\Server::get($classHandler);
398        }
399        return $this->$property;
400    }
401
402    #[\Override]
403    public function sign(): File {
404        $this->beforeSign();
405
406        $signedContent = $this->getHandler()
407            ->setCertificate($this->getCertificate())
408            ->setInputFile($this->getInputFile())
409            ->setPassword($this->getPassword())
410            ->setSignatureParams($this->getSignatureParams())
411            ->setVisibleElements($this->getVisibleElements())
412            ->getSignedContent();
413        $this->getInputFile()->putContent($signedContent);
414        return $this->getInputFile();
415    }
416
417    public function isHandlerOk(): bool {
418        return $this->certificateEngineFactory->getEngine()->isSetupOk();
419    }
420}