• 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

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

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

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

57
        #[\Override]
58
        protected function getCertificateEngineFactory(): CertificateEngineFactory {
59
                return $this->certificateEngineFactory;
20✔
60
        }
61

62
        /**
63
         * @throws LibresignException When is not a signed file
64
         */
65
        private function getSignatures($resource): iterable {
66
                rewind($resource);
18✔
67
                $content = stream_get_contents($resource);
18✔
68

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

71
                if (empty($contents[1])) {
18✔
72
                        throw new LibresignException($this->l10n->t('Unsigned file.'));
8✔
73
                }
74

75
                $seenHexSignatures = [];
10✔
76
                foreach ($contents[1] as $match) {
10✔
77
                        $signatureHex = $match[0];
10✔
78

79
                        if (isset($seenHexSignatures[$signatureHex])) {
10✔
80
                                continue;
×
81
                        }
82
                        $seenHexSignatures[$signatureHex] = true;
10✔
83

84
                        $decodedSignature = @hex2bin($signatureHex);
10✔
85
                        if ($decodedSignature === false) {
10✔
86
                                yield null;
×
87
                                continue;
×
88
                        }
89
                        yield $decodedSignature;
10✔
90
                }
91
        }
92

93
        public function setIsLibreSignFile(): void {
94
                $this->isLibreSignFile = true;
×
95
        }
96

97
        public function setPolicyUserIdForValidation(?string $userId): self {
98
                $this->policyUserIdForValidation = is_string($userId) && trim($userId) !== ''
1✔
99
                        ? trim($userId)
1✔
100
                        : null;
×
101

102
                return $this;
1✔
103
        }
104

105
        /**
106
         * @param resource $resource
107
         * @throws LibresignException When is not a signed file
108
         * @return array
109
         */
110
        #[\Override]
111
        public function getCertificateChain($resource): array {
112
                $certificates = [];
19✔
113
                $certificateEngine = $this->getCertificateEngine();
19✔
114
                $certificateEngine->setPolicyUserIdForValidation($this->policyUserIdForValidation);
19✔
115

116
                try {
117
                        $nativeMetadata = array_values($this->extractNativeSignatureMetadata($resource));
19✔
118
                        rewind($resource);
18✔
119
                        $nativeValidation = array_values($this->pdfSignatureValidationService->validateFromResource($resource));
18✔
120
                        $index = 0;
18✔
121

122
                        foreach ($this->getSignatures($resource) as $signature) {
18✔
123
                                $metadata = $nativeMetadata[$index] ?? [];
10✔
124
                                $validation = $nativeValidation[$index] ?? [];
10✔
125
                                $index++;
10✔
126

127
                                if (!$signature) {
10✔
128
                                        continue;
×
129
                                }
130

131
                                $result = $this->processSignature(
10✔
132
                                        $resource,
10✔
133
                                        $signature,
10✔
134
                                        $metadata,
10✔
135
                                        $validation
10✔
136
                                );
10✔
137

138
                                if (empty($result['chain'])) {
10✔
139
                                        continue;
4✔
140
                                }
141

142
                                $certificates[] = $result;
7✔
143
                        }
144
                } finally {
145
                        $certificateEngine->setPolicyUserIdForValidation(null);
19✔
146
                        $this->policyUserIdForValidation = null;
19✔
147
                }
148

149
                return $certificates;
10✔
150
        }
151

152
        private function processSignature($resource, ?string $signature, array $metadata = [], array $validation = []): array {
153
                $result = [];
10✔
154

155
                if (!$signature) {
10✔
NEW
156
                        $result['chain'][0]['signature_validation'] = [
×
NEW
157
                                'id' => 3,
×
NEW
158
                                'label' => $this->l10n->t('Digest mismatch.'),
×
NEW
159
                        ];
×
160
                        return $result;
×
161
                }
162

163
                $decoded = ASN1::decodeBER($signature);
10✔
164
                $result = $this->extractTimestampData($decoded, $result);
10✔
165

166
                $chain = $this->extractCertificateChain($signature);
10✔
167
                if (!empty($chain)) {
10✔
168
                        $result['chain'] = $this->orderCertificates($chain);
7✔
169
                        $result = $this->enrichLeafWithNativeData($result, $metadata, $validation);
7✔
170
                }
171

172
                $result = $this->extractDocMdpData($resource, $result);
10✔
173

174
                $result = $this->applyLibreSignRootCAFlag($result);
10✔
175
                return $result;
10✔
176
        }
177

178
        private function applyLibreSignRootCAFlag(array $signer): array {
179
                if (empty($signer['chain'])) {
10✔
180
                        return $signer;
4✔
181
                }
182

183
                foreach ($signer['chain'] as $key => $cert) {
7✔
184
                        if ($cert['isLibreSignRootCA']
7✔
185
                                && isset($cert['certificate_validation'])
7✔
186
                                && $cert['certificate_validation']['id'] !== 1
7✔
187
                        ) {
188
                                $signer['chain'][$key]['certificate_validation'] = [
×
189
                                        'id' => 1,
×
190
                                        'label' => $this->l10n->t('Certificate is trusted.'),
×
191
                                ];
×
192
                        }
193
                }
194

195
                return $signer;
7✔
196
        }
197

198
        private function extractDocMdpData($resource, array $result): array {
199
                if (empty($result['chain'])) {
10✔
200
                        return $result;
4✔
201
                }
202

203
                $docMdpData = $this->docMdpHandler->extractDocMdpData($resource);
7✔
204
                return array_merge($result, $docMdpData);
7✔
205
        }
206

207
        private function extractTimestampData(?array $decoded, array $result): array {
208
                if ($decoded === null) {
10✔
209
                        return $result;
4✔
210
                }
211

212
                $tsa = new TSA();
7✔
213

214
                $timestampData = $tsa->extract($decoded);
7✔
215
                if (!empty($timestampData['genTime']) || !empty($timestampData['policy']) || !empty($timestampData['serialNumber'])) {
7✔
216
                        $result['timestamp'] = $timestampData;
2✔
217
                }
218

219
                if (!isset($result['signingTime']) || !$result['signingTime'] instanceof \DateTime) {
7✔
220
                        $result['signingTime'] = $tsa->getSigninTime($decoded);
7✔
221
                }
222
                return $result;
7✔
223
        }
224

225
        private function extractCertificateChain(string $signature): array {
226
                $pkcs7PemSignature = $this->der2pem($signature);
10✔
227
                $pemCertificates = [];
10✔
228

229
                if (!openssl_pkcs7_read($pkcs7PemSignature, $pemCertificates)) {
10✔
230
                        return [];
4✔
231
                }
232

233
                $chain = [];
7✔
234
                $isLibreSignRootCA = false;
7✔
235
                $certificateEngine = $this->getCertificateEngine();
7✔
236

237
                foreach ($pemCertificates as $index => $pemCertificate) {
7✔
238
                        $parsed = $certificateEngine->parseCertificate($pemCertificate);
7✔
239
                        if ($parsed) {
7✔
240
                                $parsed['signature_validation'] = [
7✔
241
                                        'id' => 1,
7✔
242
                                        'label' => $this->l10n->t('Signature is valid.'),
7✔
243
                                ];
7✔
244
                                if (!$isLibreSignRootCA) {
7✔
245
                                        $isLibreSignRootCA = $this->isLibreSignRootCA($pemCertificate, $parsed);
7✔
246
                                }
247
                                $parsed['isLibreSignRootCA'] = $isLibreSignRootCA;
7✔
248
                                $chain[$index] = $parsed;
7✔
249
                        }
250
                }
251
                if ($isLibreSignRootCA || $this->isLibreSignFile) {
7✔
252
                        foreach ($chain as &$cert) {
×
253
                                $cert['isLibreSignRootCA'] = true;
×
254
                        }
255
                }
256

257
                return $chain;
7✔
258
        }
259

260
        private function isLibreSignRootCA(string $certificate, array $parsed): bool {
261
                $crlUrls = $parsed['crl_urls'] ?? [];
7✔
262
                $rootCertificatePem = is_array($crlUrls) ? $this->crlService->getRootCertificateFromCrlUrls($crlUrls) : '';
7✔
263

264
                if (empty($rootCertificatePem)) {
7✔
265
                        $rootCertificatePem = $this->getRootCertificatePem();
7✔
266
                }
267

268
                if (empty($rootCertificatePem)) {
7✔
269
                        return false;
×
270
                }
271

272
                $rootFingerprint = openssl_x509_fingerprint($rootCertificatePem, 'sha256');
7✔
273
                $fingerprint = openssl_x509_fingerprint($certificate, 'sha256');
7✔
274
                if ($rootFingerprint === $fingerprint) {
7✔
275
                        return true;
×
276
                }
277

278
                return $this->hasLibreSignCaId($parsed);
7✔
279
        }
280

281
        private function hasLibreSignCaId(array $parsed): bool {
282
                $instanceId = $this->appConfig->getValueString(Application::APP_ID, 'instance_id', '');
7✔
283
                if (strlen($instanceId) !== 10 || !isset($parsed['subject']['OU'])) {
7✔
284
                        return false;
×
285
                }
286

287
                $organizationalUnits = $parsed['subject']['OU'];
7✔
288
                if (is_string($organizationalUnits)) {
7✔
289
                        $organizationalUnits = [$organizationalUnits];
7✔
290
                }
291

292
                foreach ($organizationalUnits as $ou) {
7✔
293
                        $ou = trim((string)$ou);
7✔
294
                        if ($this->caIdentifierService->isValidCaId($ou, $instanceId)) {
7✔
295
                                return true;
×
296
                        }
297
                }
298

299
                return false;
7✔
300
        }
301

302
        private function getRootCertificatePem(): string {
303
                if (!empty($this->rootCertificatePem)) {
7✔
304
                        return $this->rootCertificatePem;
2✔
305
                }
306
                $configPath = $this->appConfig->getValueString(Application::APP_ID, 'config_path');
7✔
307
                if (empty($configPath)
7✔
308
                        || !is_dir($configPath)
7✔
309
                        || !is_readable($configPath . DIRECTORY_SEPARATOR . 'ca.pem')
7✔
310
                ) {
311
                        return '';
×
312
                }
313
                $rootCertificatePem = file_get_contents($configPath . DIRECTORY_SEPARATOR . 'ca.pem');
7✔
314
                if ($rootCertificatePem === false) {
7✔
315
                        return '';
×
316
                }
317
                $this->rootCertificatePem = $rootCertificatePem;
7✔
318
                return $this->rootCertificatePem;
7✔
319
        }
320

321
        private function enrichLeafWithNativeData(array $result, array $metadata, array $validation): array {
322
                if (empty($result['chain'])) {
7✔
323
                        return $result;
×
324
                }
325

326
                $leaf = &$result['chain'][0];
7✔
327

328
                foreach (['field', 'range', 'signature_type', 'signing_hash_algorithm', 'covers_entire_document'] as $key) {
7✔
329
                        if (array_key_exists($key, $metadata)) {
7✔
330
                                $leaf[$key] = $metadata[$key];
7✔
331
                        }
332
                }
333

334
                if (isset($validation['signatureValidation']) && is_array($validation['signatureValidation'])) {
7✔
335
                        $signatureValidation = $validation['signatureValidation'];
1✔
336

337
                        // Keep legacy OpenSSL result when native validator reports this known false-positive.
338
                        if (!$this->isDigestMismatchSignatureValidation($validation)) {
1✔
NEW
339
                                $leaf['signature_validation'] = $signatureValidation;
×
340
                        }
341
                }
342

343
                if (isset($validation['certificateValidation']) && is_array($validation['certificateValidation'])) {
7✔
344
                        $leaf['certificate_validation'] = $validation['certificateValidation'];
1✔
345
                }
346

347
                if (!isset($leaf['certificate_validation'])) {
7✔
348
                        $leaf['certificate_validation'] = [
6✔
349
                                'id' => 3,
6✔
350
                                'label' => $this->l10n->t('Certificate issuer is unknown.'),
6✔
351
                        ];
6✔
352
                }
353

354
                return $result;
7✔
355
        }
356

357
        /**
358
         * signer engines can produce signatures that the native validator currently flags as digest mismatch.
359
         * In this case we preserve the legacy validation computed from the PKCS#7 signature.
360
         */
361
        private function isDigestMismatchSignatureValidation(array $validation): bool {
362
                $rawSignatureValidation = $validation['raw']['signature'] ?? null;
1✔
363
                if ($rawSignatureValidation instanceof ValidationResult) {
1✔
364
                        return $rawSignatureValidation->reasonCode === ValidationReason::DIGEST_MISMATCH
1✔
365
                                || $rawSignatureValidation->state === ValidationState::DIGEST_MISMATCH;
1✔
366
                }
367

NEW
368
                $signatureValidation = $validation['signatureValidation'] ?? null;
×
NEW
369
                return is_array($signatureValidation) && ($signatureValidation['id'] ?? null) === 3;
×
370
        }
371

372
        /**
373
         * @param resource $resource
374
         * @return array<int, array{field: ?string, range: ?array{offset1: int, offset2: int, length1: int, length2: int}, signature_type: ?string, covers_entire_document: bool}>
375
         */
376
        private function extractNativeSignatureMetadata($resource): array {
377
                rewind($resource);
19✔
378
                $content = stream_get_contents($resource);
19✔
379
                if (!is_string($content) || $content === '') {
19✔
380
                        return [];
3✔
381
                }
382

383
                try {
384
                        $signatures = $this->extractNativeSignaturesFromContent($content);
16✔
385
                } catch (UnsignedPdfException) {
6✔
386
                        return [];
5✔
387
                }
388
                $metadata = [];
10✔
389

390
                foreach ($signatures as $index => $signature) {
10✔
391
                        $metadata[$index] = [
10✔
392
                                'field' => $signature->metadata->field,
10✔
393
                                'range' => $signature->metadata->range,
10✔
394
                                'signature_type' => $signature->metadata->signatureType,
10✔
395
                                'covers_entire_document' => $signature->metadata->coversEntireDocument,
10✔
396
                        ];
10✔
397
                }
398

399
                return $metadata;
10✔
400
        }
401

402
        protected function extractNativeSignaturesFromContent(string $content): array {
403
                return $this->pdfSignatureExtractor->extractFromString($content);
15✔
404
        }
405

406
        private function der2pem($derData) {
407
                $pem = chunk_split(base64_encode((string)$derData), 64, "\n");
10✔
408
                $pem = "-----BEGIN CERTIFICATE-----\n" . $pem . "-----END CERTIFICATE-----\n";
10✔
409
                return $pem;
10✔
410
        }
411

412
        private function getHandler(): SignEngineHandler {
413
                $sign_engine = $this->appConfig->getValueString(Application::APP_ID, 'signature_engine', 'JSignPdf');
1✔
414
                $property = lcfirst($sign_engine) . 'Handler';
1✔
415
                if (!property_exists($this, $property)) {
1✔
416
                        throw new LibresignException($this->l10n->t('Invalid Sign engine.'), 400);
×
417
                }
418
                $classHandler = 'OCA\\Libresign\\Handler\\SignEngine\\' . ucfirst($property);
1✔
419
                if (!$this->$property instanceof $classHandler) {
1✔
420
                        $this->$property = \OCP\Server::get($classHandler);
1✔
421
                }
422
                return $this->$property;
1✔
423
        }
424

425
        #[\Override]
426
        public function sign(): File {
427
                $this->beforeSign();
1✔
428

429
                $signedContent = $this->getHandler()
1✔
430
                        ->setCertificate($this->getCertificate())
1✔
431
                        ->setInputFile($this->getInputFile())
1✔
432
                        ->setPassword($this->getPassword())
1✔
433
                        ->setSignatureParams($this->getSignatureParams())
1✔
434
                        ->setVisibleElements($this->getVisibleElements())
1✔
435
                        ->getSignedContent();
1✔
436
                $this->getInputFile()->putContent($signedContent);
×
437
                return $this->getInputFile();
×
438
        }
439

440
        public function isHandlerOk(): bool {
441
                return $this->certificateEngineFactory->getEngine()->isSetupOk();
1✔
442
        }
443
}
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