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

LibreSign / libresign / 25406519220

05 May 2026 10:47PM UTC coverage: 56.814%. First build
25406519220

Pull #7596

github

web-flow
Merge 60158554f into 81feadf9e
Pull Request #7596: feat: integrate pdf-signature-validator for native validation

157 of 240 new or added lines in 4 files covered. (65.42%)

10722 of 18872 relevant lines covered (56.81%)

7.02 hits per line

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

67.28
/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 LibreSign\PdfSignatureValidator\Model\ValidationResult;
12
use LibreSign\PdfSignatureValidator\Model\ValidationState;
13
use LibreSign\PdfSignatureValidator\Parser\PdfSignatureValidator;
14
use OCA\Libresign\AppInfo\Application;
15
use OCP\IAppConfig;
16
use OCP\IL10N;
17
use Psr\Log\LoggerInterface;
18

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

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

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

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

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

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

71
        /**
72
         * Validate PDF signatures from file resource.
73
         *
74
         * @param resource $resource PDF file resource
75
         * @return list<array{signatureValidation: array, certificateValidation: array, raw: array{signature: ValidationResult, certificate: ValidationResult}}>
76
         */
77
        public function validateFromResource($resource): array {
78
                try {
79
                        $results = $this->validator->validateFromResource($resource);
8✔
80
                        return $this->mapValidationResults($results);
4✔
81
                } catch (\Throwable $e) {
4✔
82
                        $this->logger->warning('PDF signature validation failed', [
4✔
83
                                'error' => $e->getMessage(),
4✔
84
                                'trace' => $e->getTraceAsString(),
4✔
85
                        ]);
4✔
86
                        return [];
4✔
87
                }
88
        }
89

90
        /**
91
         * Validate PDF signatures from binary content.
92
         *
93
         * @param string $pdfContent Binary PDF content
94
         * @return list<array{signatureValidation: array, certificateValidation: array, raw: array{signature: ValidationResult, certificate: ValidationResult}}>
95
         */
96
        public function validateFromString(string $pdfContent): array {
97
                try {
NEW
98
                        $results = $this->validator->validateFromString($pdfContent);
×
NEW
99
                        return $this->mapValidationResults($results);
×
NEW
100
                } catch (\Throwable $e) {
×
NEW
101
                        $this->logger->warning('PDF signature validation failed', [
×
NEW
102
                                'error' => $e->getMessage(),
×
NEW
103
                                'trace' => $e->getTraceAsString(),
×
NEW
104
                        ]);
×
NEW
105
                        return [];
×
106
                }
107
        }
108

109
        /**
110
         * Map validation results from PdfSignatureValidator to LibreSign format.
111
         *
112
         * @param list<array> $results Results from PdfSignatureValidator
113
         * @return list<array{signatureValidation: array, certificateValidation: array, raw: array{signature: ValidationResult, certificate: ValidationResult}}>
114
         */
115
        private function mapValidationResults(array $results): array {
116
                $mapped = [];
4✔
117

118
                foreach ($results as $result) {
4✔
119
                        $sigValidation = $result['signatureValidation'] ?? null;
4✔
120
                        $certValidation = $result['certificateValidation'] ?? null;
4✔
121

122
                        if (!$sigValidation instanceof ValidationResult || !$certValidation instanceof ValidationResult) {
4✔
NEW
123
                                continue;
×
124
                        }
125

126
                        $mapped[] = [
4✔
127
                                'signatureValidation' => $this->mapSignatureValidation($sigValidation),
4✔
128
                                'certificateValidation' => $this->mapCertificateValidation($certValidation),
4✔
129
                                'raw' => [
4✔
130
                                        'signature' => $sigValidation,
4✔
131
                                        'certificate' => $certValidation,
4✔
132
                                ],
4✔
133
                        ];
4✔
134
                }
135

136
                return $mapped;
4✔
137
        }
138

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

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

231
        private function translateKnownReason(?string $reason): ?string {
232
                if ($reason === null || $reason === '') {
7✔
NEW
233
                        return $reason;
×
234
                }
235

236
                if (preg_match('/^Intermediate certificate at position (\d+) is not signed by issuer$/', $reason, $matches) === 1) {
7✔
237
                        // TRANSLATORS %s is the numeric position of an intermediate certificate in the chain.
NEW
238
                        return $this->l10n->t(
×
NEW
239
                                'Intermediate certificate at position %s is not signed by issuer',
×
NEW
240
                                [$matches[1]]
×
NEW
241
                        );
×
242
                }
243

244
                $prefix = 'Certificate validation failed: ';
7✔
245
                if (str_starts_with($reason, $prefix)) {
7✔
NEW
246
                        $detail = substr($reason, strlen($prefix));
×
NEW
247
                        $translatedDetail = $this->translateKnownReason($detail) ?? $detail;
×
248
                        // TRANSLATORS %s is a translated certificate validation detail message.
NEW
249
                        return $this->l10n->t('Certificate validation failed: %s', [$translatedDetail]);
×
250
                }
251

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

297
        public function isLibreSignCaLoaded(): bool {
NEW
298
                return !empty($this->libresignCaCertificate);
×
299
        }
300

301
        public function getLibreSignCaCertificate(): string {
NEW
302
                return $this->libresignCaCertificate;
×
303
        }
304
}
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