• Home
  • Features
  • Pricing
  • Docs
  • Announcements
  • Sign In

LibreSign / libresign / 28871954782

07 Jul 2026 01:58PM UTC coverage: 67.471%. First build
28871954782

Pull #7596

github

web-flow
Merge 8c9f94390 into 691b079ec
Pull Request #7596: feat: integrate pdf-signature-validator for native validation

163 of 232 new or added lines in 3 files covered. (70.26%)

16071 of 23819 relevant lines covered (67.47%)

10.95 hits per line

Source File
Press 'n' to go to next uncovered line, 'b' for previous

74.36
/lib/Service/Signature/PdfSignatureValidationService.php
1
<?php
2

3
declare(strict_types=1);
4
/**
5
 * SPDX-FileCopyrightText: 2026 LibreCode coop and contributors
6
 * SPDX-License-Identifier: AGPL-3.0-or-later
7
 */
8

9
namespace OCA\Libresign\Service\Signature;
10

11
use OCA\Libresign\AppInfo\Application;
12
use OCA\Libresign\Vendor\LibreSign\PdfSignatureValidator\Exception\UnsignedPdfException;
13
use OCA\Libresign\Vendor\LibreSign\PdfSignatureValidator\Model\ValidationResult;
14
use OCA\Libresign\Vendor\LibreSign\PdfSignatureValidator\Model\ValidationState;
15
use OCA\Libresign\Vendor\LibreSign\PdfSignatureValidator\Parser\PdfSignatureValidator;
16
use OCP\IAppConfig;
17
use OCP\IL10N;
18
use Psr\Log\LoggerInterface;
19

20
/**
21
 * Service to validate PDF signatures using the pdf-signature-validator package.
22
 *
23
 * This replaces shell calls to pdfsig with pure PHP validation.
24
 * Supports custom trusted roots (e.g., LibreSign CA) to recognize
25
 * certificates without requiring system-level CA registration.
26
 */
27
class PdfSignatureValidationService {
28
        private PdfSignatureValidator $validator;
29
        private string $libresignCaCertificate = '';
30

31
        public function __construct(
32
                private IAppConfig $appConfig,
33
                private IL10N $l10n,
34
                private LoggerInterface $logger,
35
        ) {
36
                $this->validator = new PdfSignatureValidator();
38✔
37
                $this->loadLibreSignCaCertificate();
38✔
38
        }
39

40
        private function loadLibreSignCaCertificate(): void {
41
                $configPath = $this->appConfig->getValueString(Application::APP_ID, 'config_path');
38✔
42
                if (!empty($configPath) && is_dir($configPath)) {
38✔
43
                        $caPemPath = $configPath . DIRECTORY_SEPARATOR . 'ca.pem';
32✔
44
                        if (is_readable($caPemPath)) {
32✔
45
                                $cert = @file_get_contents($caPemPath);
32✔
46
                                if ($cert !== false) {
32✔
47
                                        $this->libresignCaCertificate = $cert;
32✔
48
                                        $this->validator->addTrustedRoot($cert);
32✔
49
                                        return;
32✔
50
                                }
51
                        }
52
                }
53

54
                $alternateConfig = $this->appConfig->getValueString(
6✔
55
                        Application::APP_ID,
6✔
56
                        'libresign_ca_certificate'
6✔
57
                );
6✔
58
                if (!empty($alternateConfig)) {
6✔
NEW
59
                        $this->libresignCaCertificate = $alternateConfig;
×
NEW
60
                        $this->validator->addTrustedRoot($alternateConfig);
×
61
                }
62
        }
63

64
        public function addTrustedRoot(string $certificatePem): void {
NEW
65
                $this->validator->addTrustedRoot($certificatePem);
×
66
        }
67

68
        public function setTrustedRoots(array $certificates): void {
NEW
69
                $this->validator->setTrustedRoots($certificates);
×
70
        }
71

72
        /**
73
         * Validate PDF signatures from file resource.
74
         *
75
         * @param resource $resource PDF file resource
76
         * @return list<array{signatureValidation: array, certificateValidation: array, raw: array{signature: ValidationResult, certificate: ValidationResult}}>
77
         */
78
        public function validateFromResource($resource): array {
79
                try {
80
                        $results = $this->validateNativeFromResource($resource);
8✔
81
                } catch (UnsignedPdfException) {
4✔
82
                        return [];
4✔
83
                }
84

85
                return $this->mapValidationResults($results);
4✔
86
        }
87

88
        /**
89
         * Validate PDF signatures from binary content.
90
         *
91
         * @param string $pdfContent Binary PDF content
92
         * @return list<array{signatureValidation: array, certificateValidation: array, raw: array{signature: ValidationResult, certificate: ValidationResult}}>
93
         */
94
        public function validateFromString(string $pdfContent): array {
95
                try {
96
                        $results = $this->validateNativeFromString($pdfContent);
2✔
97
                } catch (UnsignedPdfException) {
2✔
98
                        return [];
1✔
99
                }
100

NEW
101
                return $this->mapValidationResults($results);
×
102
        }
103

104
        /**
105
         * @param resource $resource
106
         * @return list<array{signature: mixed, signatureValidation: ValidationResult, certificates: list<string>, certificateValidation: ValidationResult}>
107
         */
108
        protected function validateNativeFromResource($resource): array {
109
                return $this->validator->validateFromResource($resource);
8✔
110
        }
111

112
        /**
113
         * @return list<array{signature: mixed, signatureValidation: ValidationResult, certificates: list<string>, certificateValidation: ValidationResult}>
114
         */
115
        protected function validateNativeFromString(string $pdfContent): array {
NEW
116
                return $this->validator->validateFromString($pdfContent);
×
117
        }
118

119
        /**
120
         * Map validation results from PdfSignatureValidator to LibreSign format.
121
         *
122
         * @param list<array> $results Results from PdfSignatureValidator
123
         * @return list<array{signatureValidation: array, certificateValidation: array, raw: array{signature: ValidationResult, certificate: ValidationResult}}>
124
         */
125
        private function mapValidationResults(array $results): array {
126
                $mapped = [];
4✔
127

128
                foreach ($results as $result) {
4✔
129
                        $sigValidation = $result['signatureValidation'] ?? null;
4✔
130
                        $certValidation = $result['certificateValidation'] ?? null;
4✔
131

132
                        if (!$sigValidation instanceof ValidationResult || !$certValidation instanceof ValidationResult) {
4✔
NEW
133
                                continue;
×
134
                        }
135

136
                        $mapped[] = [
4✔
137
                                'signatureValidation' => $this->mapSignatureValidation($sigValidation),
4✔
138
                                'certificateValidation' => $this->mapCertificateValidation($certValidation),
4✔
139
                                'raw' => [
4✔
140
                                        'signature' => $sigValidation,
4✔
141
                                        'certificate' => $certValidation,
4✔
142
                                ],
4✔
143
                        ];
4✔
144
                }
145

146
                return $mapped;
4✔
147
        }
148

149
        private function mapSignatureValidation(ValidationResult $result): array {
150
                return match ($result->state) {
7✔
151
                        ValidationState::SIGNATURE_VALID => [
7✔
152
                                'id' => 1,
7✔
153
                                // TRANSLATORS User-facing status when signature cryptographic validation succeeds.
154
                                'label' => $this->l10n->t('Signature is valid.'),
7✔
155
                                'isValid' => true,
7✔
156
                        ],
7✔
157
                        ValidationState::SIGNATURE_INVALID => [
7✔
158
                                'id' => 2,
7✔
159
                                // TRANSLATORS User-facing status when signature cryptographic validation fails.
160
                                'label' => $this->l10n->t('Signature is invalid.'),
7✔
161
                                'reason' => $this->translateKnownReason($result->reason),
7✔
162
                                'isValid' => false,
7✔
163
                        ],
7✔
164
                        ValidationState::DIGEST_MISMATCH => [
7✔
165
                                'id' => 3,
7✔
166
                                // TRANSLATORS User-facing status when signed digest does not match PDF content.
167
                                'label' => $this->l10n->t('Digest mismatch.'),
7✔
168
                                'reason' => $this->translateKnownReason($result->reason),
7✔
169
                                'isValid' => false,
7✔
170
                        ],
7✔
NEW
171
                        ValidationState::NOT_VERIFIED => [
×
NEW
172
                                'id' => 5,
×
173
                                // TRANSLATORS User-facing status when validation could not be fully completed.
NEW
174
                                'label' => $this->l10n->t('Signature has not yet been verified.'),
×
NEW
175
                                'reason' => $this->translateKnownReason($result->reason),
×
NEW
176
                                'isValid' => false,
×
NEW
177
                        ],
×
178
                        default => [
7✔
179
                                'id' => 6,
7✔
180
                                // TRANSLATORS Generic fallback status for unexpected signature validation failures.
181
                                'label' => $this->l10n->t('Unknown validation failure.'),
7✔
182
                                'reason' => $this->translateKnownReason($result->reason),
7✔
183
                                'isValid' => false,
7✔
184
                        ],
7✔
185
                };
7✔
186
        }
187

188
        private function mapCertificateValidation(ValidationResult $result): array {
189
                return match ($result->state) {
5✔
190
                        ValidationState::CERT_TRUSTED => [
5✔
191
                                'id' => 1,
5✔
192
                                // TRANSLATORS User-facing status when certificate is trusted.
193
                                'label' => $this->l10n->t('Certificate is trusted.'),
5✔
194
                                'isValid' => true,
5✔
195
                        ],
5✔
196
                        ValidationState::CERT_ISSUER_NOT_TRUSTED => [
4✔
197
                                'id' => 2,
4✔
198
                                // TRANSLATORS User-facing status when issuing CA is known but not trusted.
199
                                'label' => $this->l10n->t("Certificate issuer isn't trusted."),
4✔
200
                                'reason' => $this->translateKnownReason($result->reason),
4✔
201
                                'isValid' => false,
4✔
202
                        ],
4✔
203
                        ValidationState::CERT_ISSUER_UNKNOWN => [
3✔
204
                                'id' => 3,
3✔
205
                                // TRANSLATORS User-facing status when certificate issuer cannot be identified/trusted.
206
                                'label' => $this->l10n->t('Certificate issuer is unknown.'),
3✔
207
                                'reason' => $this->translateKnownReason($result->reason),
3✔
208
                                'isValid' => false,
3✔
209
                        ],
3✔
210
                        ValidationState::CERT_REVOKED => [
3✔
211
                                'id' => 4,
3✔
212
                                // TRANSLATORS User-facing status when certificate is revoked.
213
                                'label' => $this->l10n->t('Certificate has been revoked.'),
3✔
214
                                'reason' => $this->translateKnownReason($result->reason),
3✔
215
                                'isValid' => false,
3✔
216
                        ],
3✔
217
                        ValidationState::CERT_EXPIRED => [
3✔
218
                                'id' => 5,
3✔
219
                                // TRANSLATORS User-facing status when certificate is expired.
220
                                'label' => $this->l10n->t('Certificate has expired.'),
3✔
221
                                'reason' => $this->translateKnownReason($result->reason),
3✔
222
                                'isValid' => false,
3✔
223
                        ],
3✔
224
                        ValidationState::CERT_NOT_VERIFIED => [
3✔
225
                                'id' => 6,
3✔
226
                                // TRANSLATORS User-facing status when certificate validation could not be completed.
227
                                'label' => $this->l10n->t('Certificate has not yet been verified.'),
3✔
228
                                'reason' => $this->translateKnownReason($result->reason),
3✔
229
                                'isValid' => false,
3✔
230
                        ],
3✔
231
                        default => [
5✔
232
                                'id' => 7,
5✔
233
                                // TRANSLATORS Generic fallback status for unexpected certificate validation failures.
234
                                'label' => $this->l10n->t('Unknown issue with certificate or corrupted data.'),
5✔
235
                                'reason' => $this->translateKnownReason($result->reason),
5✔
236
                                'isValid' => false,
5✔
237
                        ],
5✔
238
                };
5✔
239
        }
240

241
        private function translateKnownReason(?string $reason): ?string {
242
                if ($reason === null || $reason === '') {
7✔
NEW
243
                        return $reason;
×
244
                }
245

246
                if (preg_match('/^Intermediate certificate at position (\d+) is not signed by issuer$/', $reason, $matches) === 1) {
7✔
247
                        // TRANSLATORS %s is the numeric position of an intermediate certificate in the chain.
NEW
248
                        return $this->l10n->t(
×
NEW
249
                                'Intermediate certificate at position %s is not signed by issuer',
×
NEW
250
                                [$matches[1]]
×
NEW
251
                        );
×
252
                }
253

254
                $prefix = 'Certificate validation failed: ';
7✔
255
                if (str_starts_with($reason, $prefix)) {
7✔
NEW
256
                        $detail = substr($reason, strlen($prefix));
×
NEW
257
                        $translatedDetail = $this->translateKnownReason($detail) ?? $detail;
×
258
                        // TRANSLATORS %s is a translated certificate validation detail message.
NEW
259
                        return $this->l10n->t('Certificate validation failed: %s', [$translatedDetail]);
×
260
                }
261

262
                return match ($reason) {
7✔
263
                        // TRANSLATORS Technical term from PDF signatures. Keep "ByteRange" unchanged.
NEW
264
                        'No ByteRange in signature' => $this->l10n->t('No ByteRange in signature'),
×
265
                        // TRANSLATORS Technical message for digest/hash mismatch in PDF signature verification.
266
                        'PDF content hash does not match signed digest' => $this->l10n->t('PDF content hash does not match signed digest'),
5✔
267
                        // TRANSLATORS Certificate/public-key verification failed for signature bytes.
NEW
268
                        'Signature does not match certificate' => $this->l10n->t('Signature does not match certificate'),
×
269
                        // TRANSLATORS X.509 certificate parsing failure.
NEW
270
                        'Failed to parse certificate' => $this->l10n->t('Failed to parse certificate'),
×
271
                        // TRANSLATORS Signature timestamp is outside certificate validity window.
NEW
272
                        'Certificate was not valid at time of signature' => $this->l10n->t('Certificate was not valid at time of signature'),
×
273
                        // TRANSLATORS Certificate validity date has ended.
NEW
274
                        'Certificate has expired' => $this->l10n->t('Certificate has expired'),
×
275
                        // TRANSLATORS No certificates were found in provided certificate chain.
NEW
276
                        'Empty certificate chain' => $this->l10n->t('Empty certificate chain'),
×
277
                        // TRANSLATORS Certificate does not provide a serial number field.
NEW
278
                        'Certificate has no serial number' => $this->l10n->t('Certificate has no serial number'),
×
279
                        // TRANSLATORS CRL means Certificate Revocation List; keep acronym CRL unchanged.
NEW
280
                        'Certificate found in CRL' => $this->l10n->t('Certificate found in CRL'),
×
281
                        // TRANSLATORS Certificate structure/content is invalid.
NEW
282
                        'Invalid certificate' => $this->l10n->t('Invalid certificate'),
×
283
                        // TRANSLATORS CA means Certificate Authority; keep acronym CA unchanged.
NEW
284
                        'Leaf certificate is marked as CA' => $this->l10n->t('Leaf certificate is marked as CA'),
×
285
                        // TRANSLATORS Certificate signature chain validation failed.
286
                        'Certificate signature validation failed' => $this->l10n->t('Certificate signature validation failed'),
1✔
287
                        // TRANSLATORS Self-signed certificate is not present in trusted roots list.
NEW
288
                        'Self-signed certificate not in trusted roots' => $this->l10n->t('Self-signed certificate not in trusted roots'),
×
289
                        // TRANSLATORS Root certificate must be self-signed to be considered a trust anchor.
NEW
290
                        'Root certificate is not self-signed' => $this->l10n->t('Root certificate is not self-signed'),
×
291
                        // TRANSLATORS Root certificate is not present in configured trusted certificate list.
NEW
292
                        'Root certificate is not in trusted list' => $this->l10n->t('Root certificate is not in trusted list'),
×
293
                        // TRANSLATORS Signature container has no binary signature payload.
NEW
294
                        'No binary signature' => $this->l10n->t('No binary signature'),
×
295
                        // TRANSLATORS Signature payload has no embedded certificates.
296
                        'No certificates in signature' => $this->l10n->t('No certificates in signature'),
3✔
297
                        // TRANSLATORS Certificate used for signing is expired.
NEW
298
                        'Signing certificate has expired' => $this->l10n->t('Signing certificate has expired'),
×
299
                        // TRANSLATORS Certificate used for signing is revoked.
NEW
300
                        'Signing certificate has been revoked' => $this->l10n->t('Signing certificate has been revoked'),
×
301
                        // TRANSLATORS Signature verification could not be fully completed.
NEW
302
                        'Signature verification incomplete' => $this->l10n->t('Signature verification incomplete'),
×
303
                        default => $reason,
7✔
304
                };
7✔
305
        }
306

307
        public function isLibreSignCaLoaded(): bool {
NEW
308
                return !empty($this->libresignCaCertificate);
×
309
        }
310

311
        public function getLibreSignCaCertificate(): string {
NEW
312
                return $this->libresignCaCertificate;
×
313
        }
314
}
STATUS · Troubleshooting · Open an Issue · Sales · Support · CAREERS · ENTERPRISE · START FREE · SCHEDULE DEMO
ANNOUNCEMENTS · TWITTER · TOS & SLA · Supported CI Services · What's a CI service? · Automated Testing

© 2026 Coveralls, Inc