Code Coverage
 
Lines
Functions and Methods
Classes and Traits
Total
81.82% covered (warning)
81.82%
153 / 187
46.67% covered (danger)
46.67%
7 / 15
CRAP
0.00% covered (danger)
0.00%
0 / 1
PdfSignatureValidationService
81.82% covered (warning)
81.82%
153 / 187
46.67% covered (danger)
46.67%
7 / 15
36.78
0.00% covered (danger)
0.00%
0 / 1
 __construct
100.00% covered (success)
100.00%
2 / 2
100.00% covered (success)
100.00%
1 / 1
1
 loadLibreSignCaCertificate
87.50% covered (warning)
87.50%
14 / 16
0.00% covered (danger)
0.00%
0 / 1
6.07
 addTrustedRoot
0.00% covered (danger)
0.00%
0 / 1
0.00% covered (danger)
0.00%
0 / 1
2
 setTrustedRoots
0.00% covered (danger)
0.00%
0 / 1
0.00% covered (danger)
0.00%
0 / 1
2
 validateFromResource
100.00% covered (success)
100.00%
4 / 4
100.00% covered (success)
100.00%
1 / 1
2
 validateFromString
100.00% covered (success)
100.00%
4 / 4
100.00% covered (success)
100.00%
1 / 1
2
 validateNativeFromResource
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 validateNativeFromString
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 mapValidationResults
95.24% covered (success)
95.24%
20 / 21
0.00% covered (danger)
0.00%
0 / 1
5
 mapSignatureValidation
100.00% covered (success)
100.00%
31 / 31
100.00% covered (success)
100.00%
1 / 1
1
 mapCertificateValidation
100.00% covered (success)
100.00%
43 / 43
100.00% covered (success)
100.00%
1 / 1
1
 translateValidationReason
88.00% covered (warning)
88.00%
22 / 25
0.00% covered (danger)
0.00%
0 / 1
2.01
 translateKnownReason
31.43% covered (danger)
31.43%
11 / 35
0.00% covered (danger)
0.00%
0 / 1
13.06
 isLibreSignCaLoaded
0.00% covered (danger)
0.00%
0 / 1
0.00% covered (danger)
0.00%
0 / 1
2
 getLibreSignCaCertificate
0.00% covered (danger)
0.00%
0 / 1
0.00% covered (danger)
0.00%
0 / 1
2
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\Signature;
10
11use OCA\Libresign\AppInfo\Application;
12use OCA\Libresign\Vendor\LibreSign\PdfSignatureValidator\Exception\UnsignedPdfException;
13use OCA\Libresign\Vendor\LibreSign\PdfSignatureValidator\Model\ExtractedSignature;
14use OCA\Libresign\Vendor\LibreSign\PdfSignatureValidator\Model\ValidationReason;
15use OCA\Libresign\Vendor\LibreSign\PdfSignatureValidator\Model\ValidationResult;
16use OCA\Libresign\Vendor\LibreSign\PdfSignatureValidator\Model\ValidationState;
17use OCA\Libresign\Vendor\LibreSign\PdfSignatureValidator\Parser\PdfSignatureValidator;
18use OCP\IAppConfig;
19use OCP\IL10N;
20use Psr\Log\LoggerInterface;
21
22/**
23 * Validate PDF signatures using the pdf-signature-validator package.
24 *
25 * @psalm-import-type PdfSignatureValidationResult from \OCA\Libresign\Vendor\LibreSign\PdfSignatureValidator\Parser\PdfSignatureValidator
26 * @psalm-import-type MappedPdfValidationResult from PdfSignatureValidationDefinitions
27 */
28class PdfSignatureValidationService {
29    private PdfSignatureValidator $validator;
30    private string $libresignCaCertificate = '';
31
32    public function __construct(
33        private IAppConfig $appConfig,
34        private IL10N $l10n,
35        private LoggerInterface $logger,
36    ) {
37        $this->validator = new PdfSignatureValidator();
38        $this->loadLibreSignCaCertificate();
39    }
40
41    private function loadLibreSignCaCertificate(): void {
42        $configPath = $this->appConfig->getValueString(Application::APP_ID, 'config_path');
43        if (!empty($configPath) && is_dir($configPath)) {
44            $caPemPath = $configPath . DIRECTORY_SEPARATOR . 'ca.pem';
45            if (is_readable($caPemPath)) {
46                $cert = @file_get_contents($caPemPath);
47                if ($cert !== false) {
48                    $this->libresignCaCertificate = $cert;
49                    $this->validator->addTrustedRoot($cert);
50                    return;
51                }
52            }
53        }
54
55        $alternateConfig = $this->appConfig->getValueString(
56            Application::APP_ID,
57            'libresign_ca_certificate'
58        );
59        if (!empty($alternateConfig)) {
60            $this->libresignCaCertificate = $alternateConfig;
61            $this->validator->addTrustedRoot($alternateConfig);
62        }
63    }
64
65    public function addTrustedRoot(string $certificatePem): void {
66        $this->validator->addTrustedRoot($certificatePem);
67    }
68
69    public function setTrustedRoots(array $certificates): void {
70        $this->validator->setTrustedRoots($certificates);
71    }
72
73    /**
74     * Validate PDF signatures from file resource.
75     *
76     * @param resource $resource PDF file resource
77     * @return list<MappedPdfValidationResult>
78     */
79    public function validateFromResource($resource): array {
80        try {
81            $results = $this->validateNativeFromResource($resource);
82        } catch (UnsignedPdfException) {
83            return [];
84        }
85
86        return $this->mapValidationResults($results);
87    }
88
89    /**
90     * Validate PDF signatures from binary content.
91     *
92     * @param string $pdfContent Binary PDF content
93     * @return list<MappedPdfValidationResult>
94     */
95    public function validateFromString(string $pdfContent): array {
96        try {
97            $results = $this->validateNativeFromString($pdfContent);
98        } catch (UnsignedPdfException) {
99            return [];
100        }
101
102        return $this->mapValidationResults($results);
103    }
104
105    /**
106     * @param resource $resource
107     * @return list<PdfSignatureValidationResult>
108     */
109    protected function validateNativeFromResource($resource): array {
110        return $this->validator->validateFromResource($resource);
111    }
112
113    /**
114     * @return list<PdfSignatureValidationResult>
115     */
116    protected function validateNativeFromString(string $pdfContent): array {
117        return $this->validator->validateFromString($pdfContent);
118    }
119
120    /**
121     * Map validation results from PdfSignatureValidator to LibreSign format.
122     *
123     * @param list<PdfSignatureValidationResult> $results Results from PdfSignatureValidator
124     * @return list<MappedPdfValidationResult>
125     */
126    private function mapValidationResults(array $results): array {
127        $mapped = [];
128
129        foreach ($results as $result) {
130            $signature = $result['signature'] ?? null;
131            $sigValidation = $result['signatureValidation'] ?? null;
132            $certValidation = $result['certificateValidation'] ?? null;
133
134            if (
135                !$signature instanceof ExtractedSignature
136                || !$sigValidation instanceof ValidationResult
137                || !$certValidation instanceof ValidationResult
138            ) {
139                continue;
140            }
141
142            $mapped[] = [
143                'signature' => $signature,
144                'certificates' => $result['certificates'],
145                'timestamp' => $result['timestamp'],
146                'signatureValidation' => $this->mapSignatureValidation($sigValidation),
147                'certificateValidation' => $this->mapCertificateValidation($certValidation),
148                'raw' => [
149                    'signature' => $sigValidation,
150                    'certificate' => $certValidation,
151                ],
152            ];
153        }
154
155        return $mapped;
156    }
157
158    private function mapSignatureValidation(ValidationResult $result): array {
159        return match ($result->state) {
160            ValidationState::SIGNATURE_VALID => [
161                'id' => 1,
162                // TRANSLATORS User-facing status when signature cryptographic validation succeeds.
163                'label' => $this->l10n->t('Signature is valid.'),
164                'isValid' => true,
165            ],
166            ValidationState::SIGNATURE_INVALID => [
167                'id' => 2,
168                // TRANSLATORS User-facing status when signature cryptographic validation fails.
169                'label' => $this->l10n->t('Signature is invalid.'),
170                'reason' => $this->translateValidationReason($result),
171                'isValid' => false,
172            ],
173            ValidationState::DIGEST_MISMATCH => [
174                'id' => 3,
175                // TRANSLATORS User-facing status when signed digest does not match PDF content.
176                'label' => $this->l10n->t('Digest mismatch.'),
177                'reason' => $this->translateValidationReason($result),
178                'isValid' => false,
179            ],
180            ValidationState::NOT_VERIFIED => [
181                'id' => 5,
182                // TRANSLATORS User-facing status when validation could not be fully completed.
183                'label' => $this->l10n->t('Signature has not yet been verified.'),
184                'reason' => $this->translateValidationReason($result),
185                'isValid' => false,
186            ],
187            default => [
188                'id' => 6,
189                // TRANSLATORS Generic fallback status for unexpected signature validation failures.
190                'label' => $this->l10n->t('Unknown validation failure.'),
191                'reason' => $this->translateValidationReason($result),
192                'isValid' => false,
193            ],
194        };
195    }
196
197    private function mapCertificateValidation(ValidationResult $result): array {
198        return match ($result->state) {
199            ValidationState::CERT_TRUSTED => [
200                'id' => 1,
201                // TRANSLATORS User-facing status when certificate is trusted.
202                'label' => $this->l10n->t('Certificate is trusted.'),
203                'isValid' => true,
204            ],
205            ValidationState::CERT_ISSUER_NOT_TRUSTED => [
206                'id' => 2,
207                // TRANSLATORS User-facing status when issuing CA is known but not trusted.
208                'label' => $this->l10n->t("Certificate issuer isn't trusted."),
209                'reason' => $this->translateValidationReason($result),
210                'isValid' => false,
211            ],
212            ValidationState::CERT_ISSUER_UNKNOWN => [
213                'id' => 3,
214                // TRANSLATORS User-facing status when certificate issuer cannot be identified/trusted.
215                'label' => $this->l10n->t('Certificate issuer is unknown.'),
216                'reason' => $this->translateValidationReason($result),
217                'isValid' => false,
218            ],
219            ValidationState::CERT_REVOKED => [
220                'id' => 4,
221                // TRANSLATORS User-facing status when certificate is revoked.
222                'label' => $this->l10n->t('Certificate has been revoked.'),
223                'reason' => $this->translateValidationReason($result),
224                'isValid' => false,
225            ],
226            ValidationState::CERT_EXPIRED => [
227                'id' => 5,
228                // TRANSLATORS User-facing status when certificate is expired.
229                'label' => $this->l10n->t('Certificate has expired.'),
230                'reason' => $this->translateValidationReason($result),
231                'isValid' => false,
232            ],
233            ValidationState::CERT_NOT_VERIFIED => [
234                'id' => 6,
235                // TRANSLATORS User-facing status when certificate validation could not be completed.
236                'label' => $this->l10n->t('Certificate has not yet been verified.'),
237                'reason' => $this->translateValidationReason($result),
238                'isValid' => false,
239            ],
240            default => [
241                'id' => 7,
242                // TRANSLATORS Generic fallback status for unexpected certificate validation failures.
243                'label' => $this->l10n->t('Unknown issue with certificate or corrupted data.'),
244                'reason' => $this->translateValidationReason($result),
245                'isValid' => false,
246            ],
247        };
248    }
249
250    private function translateValidationReason(ValidationResult $result): ?string {
251        if ($result->reasonCode !== null) {
252            return match ($result->reasonCode) {
253                ValidationReason::NO_BYTE_RANGE => (
254                    // TRANSLATORS PDF digital signature validation. ByteRange is a PDF signature structure that defines exactly which byte ranges are protected by the cryptographic signature. This result means that structure is missing, so LibreSign cannot determine which document content was signed. Keep the user-facing text simple.
255                    $this->l10n->t('The signature does not contain the information needed to verify the document')
256                ),
257                ValidationReason::DIGEST_MISMATCH => (
258                    // TRANSLATORS PDF digital signature validation. LibreSign recalculated the cryptographic digest from the signed PDF byte ranges and it does not match the digest stored in the signature. This can indicate changed content or inconsistent signature data. Keep the user-facing text simple.
259                    $this->l10n->t('The document content does not match the signed content')
260                ),
261                ValidationReason::NO_BINARY_SIGNATURE => (
262                    // TRANSLATORS PDF digital signature validation. The PDF contains signature metadata, but the CMS/PKCS#7 binary signature payload required for cryptographic verification is missing. CMS/PKCS#7 is the standard container commonly used for PDF digital signatures.
263                    $this->l10n->t('The digital signature data is missing')
264                ),
265                ValidationReason::SIGNATURE_CERTIFICATE_MISMATCH => (
266                    // TRANSLATORS PDF digital signature validation. LibreSign verified the signature bytes against the signer's X.509 certificate, but the cryptographic verification failed. The certificate and the digital signature do not match as expected.
267                    $this->l10n->t('The signature could not be verified with its certificate')
268                ),
269                ValidationReason::INVALID_BYTE_RANGE => (
270                    // TRANSLATORS PDF digital signature validation. ByteRange defines which byte ranges are protected by the digital signature. The structure exists but its offsets or lengths are invalid, so cryptographic verification cannot safely continue. Keep the user-facing text simple.
271                    $this->l10n->t('The signature contains an invalid ByteRange')
272                ),
273                ValidationReason::INVALID_EOF_BOUNDARY => (
274                    // TRANSLATORS PDF digital signature validation. The signed PDF revision does not end at a valid PDF end-of-file boundary. This means the signed revision structure is invalid. Keep the user-facing text simple.
275                    $this->l10n->t('The signed PDF revision has an invalid end-of-file boundary')
276                ),
277                ValidationReason::UNSUPPORTED_SUBFILTER => (
278                    // TRANSLATORS PDF digital signature validation. SubFilter identifies the PDF signature format. This result means LibreSign does not support cryptographic validation for that format.
279                    $this->l10n->t('The PDF signature format is not supported')
280                ),
281            };
282        }
283
284        return $this->translateKnownReason($result->reason);
285    }
286
287    private function translateKnownReason(?string $reason): ?string {
288        if ($reason === null || $reason === '') {
289            return $reason;
290        }
291
292        if (preg_match('/^Intermediate certificate at position (\d+) is not signed by issuer$/', $reason, $matches) === 1) {
293            // TRANSLATORS %s is the numeric position of an intermediate certificate in the chain.
294            return $this->l10n->t(
295                'Intermediate certificate at position %s is not signed by issuer',
296                [$matches[1]]
297            );
298        }
299
300        $prefix = 'Certificate validation failed: ';
301        if (str_starts_with($reason, $prefix)) {
302            $detail = substr($reason, strlen($prefix));
303            $translatedDetail = $this->translateKnownReason($detail) ?? $detail;
304            // TRANSLATORS %s is a translated certificate validation detail message.
305            return $this->l10n->t('Certificate validation failed: %s', [$translatedDetail]);
306        }
307
308        return match ($reason) {
309            // TRANSLATORS Technical term from PDF signatures. Keep "ByteRange" unchanged.
310            'No ByteRange in signature' => $this->l10n->t('No ByteRange in signature'),
311            // TRANSLATORS Technical message for digest/hash mismatch in PDF signature verification.
312            'PDF content hash does not match signed digest' => $this->l10n->t('PDF content hash does not match signed digest'),
313            // TRANSLATORS Certificate/public-key verification failed for signature bytes.
314            'Signature does not match certificate' => $this->l10n->t('Signature does not match certificate'),
315            // TRANSLATORS X.509 certificate parsing failure.
316            'Failed to parse certificate' => $this->l10n->t('Failed to parse certificate'),
317            // TRANSLATORS Signature timestamp is outside certificate validity window.
318            'Certificate was not valid at time of signature' => $this->l10n->t('Certificate was not valid at time of signature'),
319            // TRANSLATORS Certificate validity date has ended.
320            'Certificate has expired' => $this->l10n->t('Certificate has expired'),
321            // TRANSLATORS No certificates were found in provided certificate chain.
322            'Empty certificate chain' => $this->l10n->t('Empty certificate chain'),
323            // TRANSLATORS Certificate does not provide a serial number field.
324            'Certificate has no serial number' => $this->l10n->t('Certificate has no serial number'),
325            // TRANSLATORS CRL means Certificate Revocation List; keep acronym CRL unchanged.
326            'Certificate found in CRL' => $this->l10n->t('Certificate found in CRL'),
327            // TRANSLATORS Certificate structure/content is invalid.
328            'Invalid certificate' => $this->l10n->t('Invalid certificate'),
329            // TRANSLATORS CA means Certificate Authority; keep acronym CA unchanged.
330            'Leaf certificate is marked as CA' => $this->l10n->t('Leaf certificate is marked as CA'),
331            // TRANSLATORS Certificate signature chain validation failed.
332            'Certificate signature validation failed' => $this->l10n->t('Certificate signature validation failed'),
333            // TRANSLATORS Self-signed certificate is not present in trusted roots list.
334            'Self-signed certificate not in trusted roots' => $this->l10n->t('Self-signed certificate not in trusted roots'),
335            // TRANSLATORS Root certificate must be self-signed to be considered a trust anchor.
336            'Root certificate is not self-signed' => $this->l10n->t('Root certificate is not self-signed'),
337            // TRANSLATORS Root certificate is not present in configured trusted certificate list.
338            'Root certificate is not in trusted list' => $this->l10n->t('Root certificate is not in trusted list'),
339            // TRANSLATORS Signature container has no binary signature payload.
340            'No binary signature' => $this->l10n->t('No binary signature'),
341            // TRANSLATORS Signature payload has no embedded certificates.
342            'No certificates in signature' => $this->l10n->t('No certificates in signature'),
343            // TRANSLATORS Certificate used for signing is expired.
344            'Signing certificate has expired' => $this->l10n->t('Signing certificate has expired'),
345            // TRANSLATORS Certificate used for signing is revoked.
346            'Signing certificate has been revoked' => $this->l10n->t('Signing certificate has been revoked'),
347            // TRANSLATORS Signature verification could not be fully completed.
348            'Signature verification incomplete' => $this->l10n->t('Signature verification incomplete'),
349            default => $reason,
350        };
351    }
352
353    public function isLibreSignCaLoaded(): bool {
354        return !empty($this->libresignCaCertificate);
355    }
356
357    public function getLibreSignCaCertificate(): string {
358        return $this->libresignCaCertificate;
359    }
360}