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

LibreSign / libresign / 18981726615

31 Oct 2025 06:27PM UTC coverage: 39.57%. First build
18981726615

Pull #5709

github

web-flow
Merge e60b44570 into 24f985713
Pull Request #5709: fix: trust LibreSign CA

19 of 35 new or added lines in 1 file covered. (54.29%)

4526 of 11438 relevant lines covered (39.57%)

2.91 hits per line

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

72.69
/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 DateTime;
12
use OCA\Libresign\AppInfo\Application;
13
use OCA\Libresign\Exception\LibresignException;
14
use OCA\Libresign\Handler\CertificateEngine\CertificateEngineFactory;
15
use OCA\Libresign\Handler\CertificateEngine\OrderCertificatesTrait;
16
use OCA\Libresign\Handler\FooterHandler;
17
use OCA\Libresign\Service\FolderService;
18
use OCA\Libresign\Vendor\phpseclib3\File\ASN1;
19
use OCP\Files\File;
20
use OCP\IAppConfig;
21
use OCP\IL10N;
22
use OCP\ITempManager;
23
use Psr\Log\LoggerInterface;
24

25
class Pkcs12Handler extends SignEngineHandler {
26
        use OrderCertificatesTrait;
27
        protected string $certificate = '';
28
        private array $signaturesFromPoppler = [];
29
        private ?JSignPdfHandler $jSignPdfHandler = null;
30
        private string $rootCertificatePem = '';
31

32
        public function __construct(
33
                private FolderService $folderService,
34
                private IAppConfig $appConfig,
35
                protected CertificateEngineFactory $certificateEngineFactory,
36
                private IL10N $l10n,
37
                private FooterHandler $footerHandler,
38
                private ITempManager $tempManager,
39
                private LoggerInterface $logger,
40
        ) {
41
                parent::__construct($l10n, $folderService, $logger);
51✔
42
        }
43

44
        /**
45
         * @throws LibresignException When is not a signed file
46
         */
47
        private function getSignatures($resource): iterable {
48
                $content = stream_get_contents($resource);
6✔
49
                preg_match_all(
6✔
50
                        '/ByteRange\s*\[\s*(?<offset1>\d+)\s+(?<length1>\d+)\s+(?<offset2>\d+)\s+(?<length2>\d+)\s*\]/',
6✔
51
                        $content,
6✔
52
                        $bytes
6✔
53
                );
6✔
54
                if (empty($bytes['offset1']) || empty($bytes['length1']) || empty($bytes['offset2']) || empty($bytes['length2'])) {
6✔
55
                        throw new LibresignException($this->l10n->t('Unsigned file.'));
4✔
56
                }
57

58
                for ($i = 0; $i < count($bytes['offset1']); $i++) {
2✔
59
                        $offset1 = (int)$bytes['offset1'][$i];
2✔
60
                        $length1 = (int)$bytes['length1'][$i];
2✔
61
                        $offset2 = (int)$bytes['offset2'][$i];
2✔
62

63
                        $signatureStart = $offset1 + $length1 + 1;
2✔
64
                        $signatureLength = $offset2 - $signatureStart - 1;
2✔
65

66
                        rewind($resource);
2✔
67

68
                        $signature = stream_get_contents($resource, $signatureLength, $signatureStart);
2✔
69

70
                        yield hex2bin($signature);
2✔
71
                }
72

73
                $this->tempManager->clean();
2✔
74
        }
75

76
        /**
77
         * @param resource $resource
78
         * @throws LibresignException When is not a signed file
79
         * @return array
80
         */
81
        #[\Override]
82
        public function getCertificateChain($resource): array {
83
                $certificates = [];
6✔
84

85
                foreach ($this->getSignatures($resource) as $signature) {
6✔
86
                        $certificates[] = $this->processSignature($resource, $signature);
2✔
87
                }
88
                return $certificates;
2✔
89
        }
90

91
        private function processSignature($resource, ?string $signature): array {
92
                $result = [];
2✔
93

94
                if (!$signature) {
2✔
95
                        $result['chain'][0]['signature_validation'] = $this->getReadableSigState('Digest Mismatch.');
×
96
                        return $result;
×
97
                }
98

99
                $decoded = ASN1::decodeBER($signature);
2✔
100
                $result = $this->extractTimestampData($decoded, $result);
2✔
101

102
                $chain = $this->extractCertificateChain($signature);
2✔
103
                if (!empty($chain)) {
2✔
104
                        $result['chain'] = $this->orderCertificates($chain);
2✔
105
                        $result = $this->enrichLeafWithPopplerData($resource, $result);
2✔
106
                }
107
                $result = $this->applyLibreSignRootCAFlag($result);
2✔
108
                return $result;
2✔
109
        }
110

111
        private function applyLibreSignRootCAFlag(array $signer): array {
112
                if (empty($signer['chain'])) {
2✔
NEW
113
                        return $signer;
×
114
                }
115

116
                foreach ($signer['chain'] as $key => $cert) {
2✔
117
                        if ($cert['isLibreSignRootCA'] && $cert['certificate_validation']['id'] !== 1) {
2✔
NEW
118
                                $signer['chain'][$key]['certificate_validation'] = [
×
NEW
119
                                        'id' => 1,
×
NEW
120
                                        'label' => $this->l10n->t('Certificate is trusted.'),
×
NEW
121
                                ];
×
122
                        }
123
                }
124

125
                return $signer;
2✔
126
        }
127

128
        private function extractTimestampData(array $decoded, array $result): array {
129
                $tsa = new TSA();
2✔
130

131
                try {
132
                        $timestampData = $tsa->extract($decoded);
2✔
133
                        if (!empty($timestampData['genTime']) || !empty($timestampData['policy']) || !empty($timestampData['serialNumber'])) {
2✔
134
                                $result['timestamp'] = $timestampData;
2✔
135
                        }
136
                } catch (\Throwable $e) {
×
137
                }
138

139
                if (!isset($result['signingTime']) || !$result['signingTime'] instanceof \DateTime) {
2✔
140
                        $result['signingTime'] = $tsa->getSigninTime($decoded);
2✔
141
                }
142
                return $result;
2✔
143
        }
144

145
        private function extractCertificateChain(string $signature): array {
146
                $pkcs7PemSignature = $this->der2pem($signature);
2✔
147
                $pemCertificates = [];
2✔
148

149
                if (!openssl_pkcs7_read($pkcs7PemSignature, $pemCertificates)) {
2✔
150
                        return [];
×
151
                }
152

153
                $chain = [];
2✔
154
                $isLibreSignRootCA = false;
2✔
155
                foreach ($pemCertificates as $index => $pemCertificate) {
2✔
156
                        $parsed = openssl_x509_parse($pemCertificate);
2✔
157
                        if ($parsed) {
2✔
158
                                $parsed['signature_validation'] = [
2✔
159
                                        'id' => 1,
2✔
160
                                        'label' => $this->l10n->t('Signature is valid.'),
2✔
161
                                ];
2✔
162
                                if (!$isLibreSignRootCA) {
2✔
163
                                        $isLibreSignRootCA = $this->isLibreSignRootCA($pemCertificate);
2✔
164
                                }
165
                                $parsed['isLibreSignRootCA'] = $isLibreSignRootCA;
2✔
166
                                $chain[$index] = $parsed;
2✔
167
                        }
168
                }
169
                if ($isLibreSignRootCA) {
2✔
NEW
170
                        foreach ($chain as $index => $cert) {
×
NEW
171
                                $cert['isLibreSignRootCA'] = true;
×
172
                        }
173
                }
174

175
                return $chain;
2✔
176
        }
177

178
        private function isLibreSignRootCA(string $certificate): bool {
179
                $rootCertificatePem = $this->getRootCertificatePem();
2✔
180
                if (empty($rootCertificatePem)) {
2✔
181
                        return false;
2✔
182
                }
NEW
183
                $rootFingerprint = openssl_x509_fingerprint($rootCertificatePem, 'sha256');
×
NEW
184
                $fingerprint = openssl_x509_fingerprint($certificate, 'sha256');
×
NEW
185
                return $rootFingerprint === $fingerprint;
×
186
        }
187

188
        private function getRootCertificatePem(): string {
189
                if (!empty($this->rootCertificatePem)) {
2✔
NEW
190
                        return $this->rootCertificatePem;
×
191
                }
192
                $configPath = $this->appConfig->getValueString(Application::APP_ID, 'config_path');
2✔
193
                if (empty($configPath)
2✔
194
                        || !is_dir($configPath)
2✔
195
                        || !is_readable($configPath . DIRECTORY_SEPARATOR . 'ca.pem')
2✔
196
                ) {
197
                        return '';
2✔
198
                }
NEW
199
                $rootCertificatePem = file_get_contents($configPath . DIRECTORY_SEPARATOR . 'ca.pem');
×
NEW
200
                if ($rootCertificatePem === false) {
×
NEW
201
                        return '';
×
202
                }
NEW
203
                $this->rootCertificatePem = $rootCertificatePem;
×
NEW
204
                return $this->rootCertificatePem;
×
205
        }
206

207
        private function enrichLeafWithPopplerData($resource, array $result): array {
208
                if (empty($result['chain'])) {
2✔
209
                        return $result;
×
210
                }
211

212
                $popplerOnlyFields = ['field', 'range', 'certificate_validation'];
2✔
213
                if (!isset($result['chain'][0]['subject'])) {
2✔
214
                        return $result;
×
215
                }
216
                $needPoppler = false;
2✔
217
                foreach ($popplerOnlyFields as $field) {
2✔
218
                        if (empty($result['chain'][0][$field])) {
2✔
219
                                $needPoppler = true;
2✔
220
                                break;
2✔
221
                        }
222
                }
223
                if (!isset($result['chain'][0]['signature_validation']) || $result['chain'][0]['signature_validation']['id'] !== 1) {
2✔
224
                        $needPoppler = true;
×
225
                }
226
                if (!$needPoppler) {
2✔
227
                        return $result;
×
228
                }
229
                $popplerChain = $this->chainFromPoppler($result['chain'][0]['subject'], $resource);
2✔
230
                if (empty($popplerChain)) {
2✔
231
                        return $result;
×
232
                }
233
                foreach ($popplerOnlyFields as $field) {
2✔
234
                        if (isset($popplerChain[$field])) {
2✔
235
                                $result['chain'][0][$field] = $popplerChain[$field];
2✔
236
                        }
237
                }
238
                if (!isset($result['chain'][0]['signature_validation']) || $result['chain'][0]['signature_validation']['id'] !== 1) {
2✔
239
                        if (isset($popplerChain['signature_validation'])) {
×
240
                                $result['chain'][0]['signature_validation'] = $popplerChain['signature_validation'];
×
241
                        }
242
                }
243
                return $result;
2✔
244
        }
245

246
        private function chainFromPoppler(array $subject, $resource): array {
247
                $fromFallback = $this->popplerUtilsPdfSignFallback($resource);
2✔
248
                foreach ($fromFallback as $popplerSig) {
2✔
249
                        if (!isset($popplerSig['chain'][0]['subject'])) {
2✔
250
                                continue;
×
251
                        }
252
                        if ($popplerSig['chain'][0]['subject'] == $subject) {
2✔
253
                                return $popplerSig['chain'][0];
2✔
254
                        }
255
                }
256
                return [];
×
257
        }
258

259
        private function popplerUtilsPdfSignFallback($resource): array {
260
                if (!empty($this->signaturesFromPoppler)) {
2✔
261
                        return $this->signaturesFromPoppler;
1✔
262
                }
263
                if (shell_exec('which pdfsig') === null) {
1✔
264
                        return $this->signaturesFromPoppler;
×
265
                }
266
                rewind($resource);
1✔
267
                $content = stream_get_contents($resource);
1✔
268
                $tempFile = $this->tempManager->getTemporaryFile('file.pdf');
1✔
269
                file_put_contents($tempFile, $content);
1✔
270

271
                $content = shell_exec('env TZ=UTC pdfsig ' . $tempFile);
1✔
272
                if (empty($content)) {
1✔
273
                        return $this->signaturesFromPoppler;
×
274
                }
275
                $lines = explode("\n", $content);
1✔
276

277
                $lastSignature = 0;
1✔
278
                foreach ($lines as $item) {
1✔
279
                        $isFirstLevel = preg_match('/^Signature\s#(\d)/', $item, $match);
1✔
280
                        if ($isFirstLevel) {
1✔
281
                                $lastSignature = (int)$match[1] - 1;
1✔
282
                                $this->signaturesFromPoppler[$lastSignature] = [];
1✔
283
                                continue;
1✔
284
                        }
285

286
                        $match = [];
1✔
287
                        $isSecondLevel = preg_match('/^\s+-\s(?<key>.+):\s(?<value>.*)/', $item, $match);
1✔
288
                        if ($isSecondLevel) {
1✔
289
                                switch ((string)$match['key']) {
1✔
290
                                        case 'Signing Time':
1✔
291
                                                $this->signaturesFromPoppler[$lastSignature]['signingTime'] = DateTime::createFromFormat('M d Y H:i:s', $match['value'], new \DateTimeZone('UTC'));
1✔
292
                                                break;
1✔
293
                                        case 'Signer full Distinguished Name':
1✔
294
                                                $this->signaturesFromPoppler[$lastSignature]['chain'][0]['subject'] = $this->parseDistinguishedNameWithMultipleValues($match['value']);
1✔
295
                                                $this->signaturesFromPoppler[$lastSignature]['chain'][0]['name'] = $match['value'];
1✔
296
                                                break;
1✔
297
                                        case 'Signing Hash Algorithm':
1✔
298
                                                $this->signaturesFromPoppler[$lastSignature]['chain'][0]['signatureTypeSN'] = $match['value'];
1✔
299
                                                break;
1✔
300
                                        case 'Signature Validation':
1✔
301
                                                $this->signaturesFromPoppler[$lastSignature]['chain'][0]['signature_validation'] = $this->getReadableSigState($match['value']);
1✔
302
                                                break;
1✔
303
                                        case 'Certificate Validation':
1✔
304
                                                $this->signaturesFromPoppler[$lastSignature]['chain'][0]['certificate_validation'] = $this->getReadableCertState($match['value']);
1✔
305
                                                break;
1✔
306
                                        case 'Signed Ranges':
1✔
307
                                                if (preg_match('/\[(\d+) - (\d+)\], \[(\d+) - (\d+)\]/', $match['value'], $ranges)) {
1✔
308
                                                        $this->signaturesFromPoppler[$lastSignature]['chain'][0]['range'] = [
1✔
309
                                                                'offset1' => (int)$ranges[1],
1✔
310
                                                                'length1' => (int)$ranges[2],
1✔
311
                                                                'offset2' => (int)$ranges[3],
1✔
312
                                                                'length2' => (int)$ranges[4],
1✔
313
                                                        ];
1✔
314
                                                }
315
                                                break;
1✔
316
                                        case 'Signature Field Name':
1✔
317
                                                $this->signaturesFromPoppler[$lastSignature]['chain'][0]['field'] = $match['value'];
1✔
318
                                                break;
1✔
319
                                        case 'Signature Validation':
1✔
320
                                        case 'Signature Type':
1✔
321
                                        case 'Total document signed':
1✔
322
                                        case 'Not total document signed':
1✔
323
                                        default:
324
                                                break;
1✔
325
                                }
326
                        }
327
                }
328
                return $this->signaturesFromPoppler;
1✔
329
        }
330

331
        private function getReadableSigState(string $status) {
332
                return match ($status) {
1✔
333
                        'Signature is Valid.' => [
1✔
334
                                'id' => 1,
1✔
335
                                'label' => $this->l10n->t('Signature is valid.'),
1✔
336
                        ],
1✔
337
                        'Signature is Invalid.' => [
×
338
                                'id' => 2,
×
339
                                'label' => $this->l10n->t('Signature is invalid.'),
×
340
                        ],
×
341
                        'Digest Mismatch.' => [
×
342
                                'id' => 3,
×
343
                                'label' => $this->l10n->t('Digest mismatch.'),
×
344
                        ],
×
345
                        "Document isn't signed or corrupted data." => [
×
346
                                'id' => 4,
×
347
                                'label' => $this->l10n->t("Document isn't signed or corrupted data."),
×
348
                        ],
×
349
                        'Signature has not yet been verified.' => [
×
350
                                'id' => 5,
×
351
                                'label' => $this->l10n->t('Signature has not yet been verified.'),
×
352
                        ],
×
353
                        default => [
1✔
354
                                'id' => 6,
1✔
355
                                'label' => $this->l10n->t('Unknown validation failure.'),
1✔
356
                        ],
1✔
357
                };
1✔
358
        }
359

360

361
        private function getReadableCertState(string $status) {
362
                return match ($status) {
1✔
363
                        'Certificate is Trusted.' => [
×
364
                                'id' => 1,
×
365
                                'label' => $this->l10n->t('Certificate is trusted.'),
×
366
                        ],
×
367
                        "Certificate issuer isn't Trusted." => [
×
368
                                'id' => 2,
×
369
                                'label' => $this->l10n->t("Certificate issuer isn't trusted."),
×
370
                        ],
×
371
                        'Certificate issuer is unknown.' => [
1✔
372
                                'id' => 3,
1✔
373
                                'label' => $this->l10n->t('Certificate issuer is unknown.'),
1✔
374
                        ],
1✔
375
                        'Certificate has been Revoked.' => [
×
376
                                'id' => 4,
×
377
                                'label' => $this->l10n->t('Certificate has been revoked.'),
×
378
                        ],
×
379
                        'Certificate has Expired' => [
×
380
                                'id' => 5,
×
381
                                'label' => $this->l10n->t('Certificate has expired'),
×
382
                        ],
×
383
                        'Certificate has not yet been verified.' => [
×
384
                                'id' => 6,
×
385
                                'label' => $this->l10n->t('Certificate has not yet been verified.'),
×
386
                        ],
×
387
                        default => [
1✔
388
                                'id' => 7,
1✔
389
                                'label' => $this->l10n->t('Unknown issue with Certificate or corrupted data.')
1✔
390
                        ],
1✔
391
                };
1✔
392
        }
393

394

395
        private function parseDistinguishedNameWithMultipleValues(string $dn): array {
396
                $result = [];
1✔
397
                $pairs = preg_split('/,(?=(?:[^"]*"[^"]*")*[^"]*$)/', $dn);
1✔
398

399
                foreach ($pairs as $pair) {
1✔
400
                        [$key, $value] = explode('=', $pair, 2);
1✔
401
                        if (empty($key) || empty($value)) {
1✔
402
                                return $result;
×
403
                        }
404
                        $key = trim($key);
1✔
405
                        $value = trim($value);
1✔
406
                        $value = trim($value, '"');
1✔
407

408
                        if (!isset($result[$key])) {
1✔
409
                                $result[$key] = $value;
1✔
410
                        } else {
411
                                if (!is_array($result[$key])) {
×
412
                                        $result[$key] = [$result[$key]];
×
413
                                }
414
                                $result[$key][] = $value;
×
415
                        }
416
                }
417

418
                return $result;
1✔
419
        }
420

421
        private function der2pem($derData) {
422
                $pem = chunk_split(base64_encode((string)$derData), 64, "\n");
2✔
423
                $pem = "-----BEGIN CERTIFICATE-----\n" . $pem . "-----END CERTIFICATE-----\n";
2✔
424
                return $pem;
2✔
425
        }
426

427
        private function getHandler(): SignEngineHandler {
428
                $sign_engine = $this->appConfig->getValueString(Application::APP_ID, 'sign_engine', 'JSignPdf');
1✔
429
                $property = lcfirst($sign_engine) . 'Handler';
1✔
430
                if (!property_exists($this, $property)) {
1✔
431
                        throw new LibresignException($this->l10n->t('Invalid Sign engine.'), 400);
×
432
                }
433
                $classHandler = 'OCA\\Libresign\\Handler\\SignEngine\\' . ucfirst($property);
1✔
434
                if (!$this->$property instanceof $classHandler) {
1✔
435
                        $this->$property = \OCP\Server::get($classHandler);
1✔
436
                }
437
                return $this->$property;
1✔
438
        }
439

440
        #[\Override]
441
        public function sign(): File {
442
                $signedContent = $this->getHandler()
1✔
443
                        ->setCertificate($this->getCertificate())
1✔
444
                        ->setInputFile($this->getInputFile())
1✔
445
                        ->setPassword($this->getPassword())
1✔
446
                        ->setSignatureParams($this->getSignatureParams())
1✔
447
                        ->setVisibleElements($this->getVisibleElements())
1✔
448
                        ->getSignedContent();
1✔
449
                $this->getInputFile()->putContent($signedContent);
×
450
                return $this->getInputFile();
×
451
        }
452

453
        public function isHandlerOk(): bool {
454
                return $this->certificateEngineFactory->getEngine()->isSetupOk();
1✔
455
        }
456
}
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