• 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

73.98
/lib/Handler/SignEngine/Pkcs12Handler.php
1
<?php
2

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

9
namespace OCA\Libresign\Handler\SignEngine;
10

11
use LibreSign\PdfSignatureValidator\Model\ValidationReason;
12
use LibreSign\PdfSignatureValidator\Model\ValidationResult;
13
use LibreSign\PdfSignatureValidator\Model\ValidationState;
14
use LibreSign\PdfSignatureValidator\Parser\PdfSignatureExtractor;
15
use OCA\Libresign\AppInfo\Application;
16
use OCA\Libresign\Exception\LibresignException;
17
use OCA\Libresign\Handler\CertificateEngine\CertificateEngineFactory;
18
use OCA\Libresign\Handler\CertificateEngine\OrderCertificatesTrait;
19
use OCA\Libresign\Handler\DocMdpHandler;
20
use OCA\Libresign\Handler\FooterHandler;
21
use OCA\Libresign\Service\CaIdentifierService;
22
use OCA\Libresign\Service\Crl\CrlService;
23
use OCA\Libresign\Service\FolderService;
24
use OCA\Libresign\Service\Signature\PdfSignatureValidationService;
25
use OCP\Files\File;
26
use OCP\IAppConfig;
27
use OCP\IL10N;
28
use phpseclib3\File\ASN1;
29
use Psr\Log\LoggerInterface;
30

31
class Pkcs12Handler extends SignEngineHandler {
32
        use OrderCertificatesTrait;
33
        protected string $certificate = '';
34
        private ?JSignPdfHandler $jSignPdfHandler = null;
35
        private ?PhpNativeHandler $phpNativeHandler = null;
36
        private string $rootCertificatePem = '';
37
        private bool $isLibreSignFile = false;
38

39
        public function __construct(
40
                private FolderService $folderService,
41
                private IAppConfig $appConfig,
42
                protected CertificateEngineFactory $certificateEngineFactory,
43
                private IL10N $l10n,
44
                private FooterHandler $footerHandler,
45
                private LoggerInterface $logger,
46
                private CaIdentifierService $caIdentifierService,
47
                private DocMdpHandler $docMdpHandler,
48
                private CrlService $crlService,
49
                private PdfSignatureValidationService $pdfSignatureValidationService,
50
                private PdfSignatureExtractor $pdfSignatureExtractor,
51
        ) {
52
                parent::__construct($l10n, $folderService, $logger);
82✔
53
        }
54

55
        /**
56
         * @throws LibresignException When is not a signed file
57
         */
58
        private function getSignatures($resource): iterable {
59
                rewind($resource);
17✔
60
                $content = stream_get_contents($resource);
17✔
61

62
                preg_match_all('/\/Contents\s*<([0-9a-fA-F]+)>/', $content, $contents, PREG_OFFSET_CAPTURE);
17✔
63

64
                if (empty($contents[1])) {
17✔
65
                        throw new LibresignException($this->l10n->t('Unsigned file.'));
8✔
66
                }
67

68
                $seenHexSignatures = [];
9✔
69
                foreach ($contents[1] as $match) {
9✔
70
                        $signatureHex = $match[0];
9✔
71

72
                        if (isset($seenHexSignatures[$signatureHex])) {
9✔
73
                                continue;
×
74
                        }
75
                        $seenHexSignatures[$signatureHex] = true;
9✔
76

77
                        $decodedSignature = @hex2bin($signatureHex);
9✔
78
                        if ($decodedSignature === false) {
9✔
79
                                yield null;
×
80
                                continue;
×
81
                        }
82
                        yield $decodedSignature;
9✔
83
                }
84
        }
85

86
        public function setIsLibreSignFile(): void {
87
                $this->isLibreSignFile = true;
×
88
        }
89

90
        /**
91
         * @param resource $resource
92
         * @throws LibresignException When is not a signed file
93
         * @return array
94
         */
95
        #[\Override]
96
        public function getCertificateChain($resource): array {
97
                $certificates = [];
17✔
98
                $nativeMetadata = array_values($this->extractNativeSignatureMetadata($resource));
17✔
99
                rewind($resource);
17✔
100
                $nativeValidation = array_values($this->pdfSignatureValidationService->validateFromResource($resource));
17✔
101
                $index = 0;
17✔
102

103
                foreach ($this->getSignatures($resource) as $signature) {
17✔
104
                        $metadata = $nativeMetadata[$index] ?? [];
9✔
105
                        $validation = $nativeValidation[$index] ?? [];
9✔
106
                        $index++;
9✔
107

108
                        if (!$signature) {
9✔
109
                                continue;
×
110
                        }
111

112
                        $result = $this->processSignature(
9✔
113
                                $resource,
9✔
114
                                $signature,
9✔
115
                                $metadata,
9✔
116
                                $validation
9✔
117
                        );
9✔
118

119
                        if (empty($result['chain'])) {
6✔
120
                                continue;
×
121
                        }
122

123
                        $certificates[] = $result;
6✔
124
                }
125
                return $certificates;
6✔
126
        }
127

128
        private function processSignature($resource, ?string $signature, array $metadata = [], array $validation = []): array {
129
                $result = [];
9✔
130

131
                if (!$signature) {
9✔
NEW
132
                        $result['chain'][0]['signature_validation'] = [
×
NEW
133
                                'id' => 3,
×
NEW
134
                                'label' => $this->l10n->t('Digest mismatch.'),
×
NEW
135
                        ];
×
136
                        return $result;
×
137
                }
138

139
                $decoded = ASN1::decodeBER($signature);
9✔
140
                $result = $this->extractTimestampData($decoded, $result);
9✔
141

142
                $chain = $this->extractCertificateChain($signature);
6✔
143
                if (!empty($chain)) {
6✔
144
                        $result['chain'] = $this->orderCertificates($chain);
6✔
145
                        $result = $this->enrichLeafWithNativeData($result, $metadata, $validation);
6✔
146
                }
147

148
                $result = $this->extractDocMdpData($resource, $result);
6✔
149

150
                $result = $this->applyLibreSignRootCAFlag($result);
6✔
151
                return $result;
6✔
152
        }
153

154
        private function applyLibreSignRootCAFlag(array $signer): array {
155
                if (empty($signer['chain'])) {
6✔
156
                        return $signer;
×
157
                }
158

159
                foreach ($signer['chain'] as $key => $cert) {
6✔
160
                        if ($cert['isLibreSignRootCA']
6✔
161
                                && isset($cert['certificate_validation'])
6✔
162
                                && $cert['certificate_validation']['id'] !== 1
6✔
163
                        ) {
164
                                $signer['chain'][$key]['certificate_validation'] = [
×
165
                                        'id' => 1,
×
166
                                        'label' => $this->l10n->t('Certificate is trusted.'),
×
167
                                ];
×
168
                        }
169
                }
170

171
                return $signer;
6✔
172
        }
173

174

175
        private function extractDocMdpData($resource, array $result): array {
176
                if (empty($result['chain'])) {
6✔
177
                        return $result;
×
178
                }
179

180
                $docMdpData = $this->docMdpHandler->extractDocMdpData($resource);
6✔
181
                return array_merge($result, $docMdpData);
6✔
182
        }
183

184
        private function extractTimestampData(array $decoded, array $result): array {
185
                $tsa = new TSA();
6✔
186

187
                try {
188
                        $timestampData = $tsa->extract($decoded);
6✔
189
                        if (!empty($timestampData['genTime']) || !empty($timestampData['policy']) || !empty($timestampData['serialNumber'])) {
6✔
190
                                $result['timestamp'] = $timestampData;
6✔
191
                        }
192
                } catch (\Throwable) {
×
193
                }
194

195
                if (!isset($result['signingTime']) || !$result['signingTime'] instanceof \DateTime) {
6✔
196
                        $result['signingTime'] = $tsa->getSigninTime($decoded);
6✔
197
                }
198
                return $result;
6✔
199
        }
200

201
        private function extractCertificateChain(string $signature): array {
202
                $pkcs7PemSignature = $this->der2pem($signature);
6✔
203
                $pemCertificates = [];
6✔
204

205
                if (!openssl_pkcs7_read($pkcs7PemSignature, $pemCertificates)) {
6✔
206
                        return [];
×
207
                }
208

209
                $chain = [];
6✔
210
                $isLibreSignRootCA = false;
6✔
211
                $certificateEngine = $this->getCertificateEngine();
6✔
212

213
                foreach ($pemCertificates as $index => $pemCertificate) {
6✔
214
                        $parsed = $certificateEngine->parseCertificate($pemCertificate);
6✔
215
                        if ($parsed) {
6✔
216
                                $parsed['signature_validation'] = [
6✔
217
                                        'id' => 1,
6✔
218
                                        'label' => $this->l10n->t('Signature is valid.'),
6✔
219
                                ];
6✔
220
                                if (!$isLibreSignRootCA) {
6✔
221
                                        $isLibreSignRootCA = $this->isLibreSignRootCA($pemCertificate, $parsed);
6✔
222
                                }
223
                                $parsed['isLibreSignRootCA'] = $isLibreSignRootCA;
6✔
224
                                $chain[$index] = $parsed;
6✔
225
                        }
226
                }
227
                if ($isLibreSignRootCA || $this->isLibreSignFile) {
6✔
228
                        foreach ($chain as &$cert) {
×
229
                                $cert['isLibreSignRootCA'] = true;
×
230
                        }
231
                }
232

233
                return $chain;
6✔
234
        }
235

236
        private function isLibreSignRootCA(string $certificate, array $parsed): bool {
237
                $crlUrls = $parsed['crl_urls'] ?? [];
6✔
238
                $rootCertificatePem = is_array($crlUrls) ? $this->crlService->getRootCertificateFromCrlUrls($crlUrls) : '';
6✔
239

240
                if (empty($rootCertificatePem)) {
6✔
241
                        $rootCertificatePem = $this->getRootCertificatePem();
6✔
242
                }
243

244
                if (empty($rootCertificatePem)) {
6✔
245
                        return false;
6✔
246
                }
247

248
                $rootFingerprint = openssl_x509_fingerprint($rootCertificatePem, 'sha256');
×
249
                $fingerprint = openssl_x509_fingerprint($certificate, 'sha256');
×
250
                if ($rootFingerprint === $fingerprint) {
×
251
                        return true;
×
252
                }
253

254
                return $this->hasLibreSignCaId($parsed);
×
255
        }
256

257
        private function hasLibreSignCaId(array $parsed): bool {
258
                $instanceId = $this->appConfig->getValueString(Application::APP_ID, 'instance_id', '');
×
259
                if (strlen($instanceId) !== 10 || !isset($parsed['subject']['OU'])) {
×
260
                        return false;
×
261
                }
262

263
                $organizationalUnits = $parsed['subject']['OU'];
×
264
                if (is_string($organizationalUnits)) {
×
265
                        $organizationalUnits = [$organizationalUnits];
×
266
                }
267

268
                foreach ($organizationalUnits as $ou) {
×
269
                        $ou = trim((string)$ou);
×
270
                        if ($this->caIdentifierService->isValidCaId($ou, $instanceId)) {
×
271
                                return true;
×
272
                        }
273
                }
274

275
                return false;
×
276
        }
277

278
        private function getRootCertificatePem(): string {
279
                if (!empty($this->rootCertificatePem)) {
6✔
280
                        return $this->rootCertificatePem;
×
281
                }
282
                $configPath = $this->appConfig->getValueString(Application::APP_ID, 'config_path');
6✔
283
                if (empty($configPath)
6✔
284
                        || !is_dir($configPath)
×
285
                        || !is_readable($configPath . DIRECTORY_SEPARATOR . 'ca.pem')
6✔
286
                ) {
287
                        return '';
6✔
288
                }
289
                $rootCertificatePem = file_get_contents($configPath . DIRECTORY_SEPARATOR . 'ca.pem');
×
290
                if ($rootCertificatePem === false) {
×
291
                        return '';
×
292
                }
293
                $this->rootCertificatePem = $rootCertificatePem;
×
294
                return $this->rootCertificatePem;
×
295
        }
296

297
        private function enrichLeafWithNativeData(array $result, array $metadata, array $validation): array {
298
                if (empty($result['chain'])) {
6✔
299
                        return $result;
×
300
                }
301

302
                $leaf = &$result['chain'][0];
6✔
303

304
                foreach (['field', 'range', 'signature_type', 'signing_hash_algorithm', 'covers_entire_document'] as $key) {
6✔
305
                        if (array_key_exists($key, $metadata)) {
6✔
306
                                $leaf[$key] = $metadata[$key];
6✔
307
                        }
308
                }
309

310
                if (isset($validation['signatureValidation']) && is_array($validation['signatureValidation'])) {
6✔
311
                        $signatureValidation = $validation['signatureValidation'];
1✔
312

313
                        // Keep legacy OpenSSL result when native validator reports this known false-positive.
314
                        if (!$this->isDigestMismatchSignatureValidation($validation)) {
1✔
NEW
315
                                $leaf['signature_validation'] = $signatureValidation;
×
316
                        }
317
                }
318

319
                if (isset($validation['certificateValidation']) && is_array($validation['certificateValidation'])) {
6✔
320
                        $leaf['certificate_validation'] = $validation['certificateValidation'];
1✔
321
                }
322

323
                if (!isset($leaf['certificate_validation'])) {
6✔
324
                        $leaf['certificate_validation'] = [
5✔
325
                                'id' => 3,
5✔
326
                                'label' => $this->l10n->t('Certificate issuer is unknown.'),
5✔
327
                        ];
5✔
328
                }
329

330
                return $result;
6✔
331
        }
332

333
        /**
334
         * signer engines can produce signatures that the native validator currently flags as digest mismatch.
335
         * In this case we preserve the legacy validation computed from the PKCS#7 signature.
336
         */
337
        private function isDigestMismatchSignatureValidation(array $validation): bool {
338
                $rawSignatureValidation = $validation['raw']['signature'] ?? null;
1✔
339
                if ($rawSignatureValidation instanceof ValidationResult) {
1✔
340
                        return $rawSignatureValidation->reasonCode === ValidationReason::DIGEST_MISMATCH
1✔
341
                                || $rawSignatureValidation->state === ValidationState::DIGEST_MISMATCH;
1✔
342
                }
343

NEW
344
                $signatureValidation = $validation['signatureValidation'] ?? null;
×
NEW
345
                return is_array($signatureValidation) && ($signatureValidation['id'] ?? null) === 3;
×
346
        }
347

348
        /**
349
         * @param resource $resource
350
         * @return array<int, array{field: ?string, range: ?array{offset1: int, offset2: int, length1: int, length2: int}, signature_type: ?string, covers_entire_document: bool}>
351
         */
352
        private function extractNativeSignatureMetadata($resource): array {
353
                rewind($resource);
17✔
354
                $content = stream_get_contents($resource);
17✔
355
                if (!is_string($content) || $content === '') {
17✔
356
                        return [];
3✔
357
                }
358

359
                try {
360
                        $signatures = $this->pdfSignatureExtractor->extractFromString($content);
14✔
361
                } catch (\Throwable) {
5✔
362
                        return [];
5✔
363
                }
364
                $metadata = [];
9✔
365

366
                foreach ($signatures as $index => $signature) {
9✔
367
                        $metadata[$index] = [
9✔
368
                                'field' => $signature->metadata->field,
9✔
369
                                'range' => $signature->metadata->range,
9✔
370
                                'signature_type' => $signature->metadata->signatureType,
9✔
371
                                'covers_entire_document' => $signature->metadata->coversEntireDocument,
9✔
372
                        ];
9✔
373
                }
374

375
                return $metadata;
9✔
376
        }
377

378
        private function der2pem($derData) {
379
                $pem = chunk_split(base64_encode((string)$derData), 64, "\n");
6✔
380
                $pem = "-----BEGIN CERTIFICATE-----\n" . $pem . "-----END CERTIFICATE-----\n";
6✔
381
                return $pem;
6✔
382
        }
383

384
        private function getHandler(): SignEngineHandler {
385
                $sign_engine = $this->appConfig->getValueString(Application::APP_ID, 'signature_engine', 'JSignPdf');
1✔
386
                $property = lcfirst($sign_engine) . 'Handler';
1✔
387
                if (!property_exists($this, $property)) {
1✔
388
                        throw new LibresignException($this->l10n->t('Invalid Sign engine.'), 400);
×
389
                }
390
                $classHandler = 'OCA\\Libresign\\Handler\\SignEngine\\' . ucfirst($property);
1✔
391
                if (!$this->$property instanceof $classHandler) {
1✔
392
                        $this->$property = \OCP\Server::get($classHandler);
1✔
393
                }
394
                return $this->$property;
1✔
395
        }
396

397
        #[\Override]
398
        public function sign(): File {
399
                $this->beforeSign();
1✔
400

401
                $signedContent = $this->getHandler()
1✔
402
                        ->setCertificate($this->getCertificate())
1✔
403
                        ->setInputFile($this->getInputFile())
1✔
404
                        ->setPassword($this->getPassword())
1✔
405
                        ->setSignatureParams($this->getSignatureParams())
1✔
406
                        ->setVisibleElements($this->getVisibleElements())
1✔
407
                        ->getSignedContent();
1✔
408
                $this->getInputFile()->putContent($signedContent);
×
409
                return $this->getInputFile();
×
410
        }
411

412
        public function isHandlerOk(): bool {
413
                return $this->certificateEngineFactory->getEngine()->isSetupOk();
1✔
414
        }
415
}
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