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

LibreSign / libresign / 24083305764

07 Apr 2026 01:14PM UTC coverage: 55.599%. First build
24083305764

Pull #7450

github

web-flow
Merge 99a97f498 into 1112b1165
Pull Request #7450: chore(rector): apply safe test-only batch and php82 baseline

5 of 20 new or added lines in 15 files covered. (25.0%)

10233 of 18405 relevant lines covered (55.6%)

6.61 hits per line

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

68.33
/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\DocMdpHandler;
17
use OCA\Libresign\Handler\FooterHandler;
18
use OCA\Libresign\Service\CaIdentifierService;
19
use OCA\Libresign\Service\Crl\CrlService;
20
use OCA\Libresign\Service\FolderService;
21
use OCP\Files\File;
22
use OCP\IAppConfig;
23
use OCP\IL10N;
24
use OCP\ITempManager;
25
use phpseclib3\File\ASN1;
26
use Psr\Log\LoggerInterface;
27

28
class Pkcs12Handler extends SignEngineHandler {
29
        use OrderCertificatesTrait;
30
        protected string $certificate = '';
31
        private array $signaturesFromPoppler = [];
32
        private ?JSignPdfHandler $jSignPdfHandler = null;
33
        private ?PhpNativeHandler $phpNativeHandler = null;
34
        private string $rootCertificatePem = '';
35
        private bool $isLibreSignFile = false;
36

37
        public function __construct(
38
                private FolderService $folderService,
39
                private IAppConfig $appConfig,
40
                protected CertificateEngineFactory $certificateEngineFactory,
41
                private IL10N $l10n,
42
                private FooterHandler $footerHandler,
43
                private ITempManager $tempManager,
44
                private LoggerInterface $logger,
45
                private CaIdentifierService $caIdentifierService,
46
                private DocMdpHandler $docMdpHandler,
47
                private CrlService $crlService,
48
        ) {
49
                parent::__construct($l10n, $folderService, $logger);
77✔
50
        }
51

52
        /**
53
         * @throws LibresignException When is not a signed file
54
         */
55
        private function getSignatures($resource): iterable {
56
                rewind($resource);
14✔
57
                $content = stream_get_contents($resource);
14✔
58

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

61
                if (empty($contents[1])) {
14✔
62
                        throw new LibresignException($this->l10n->t('Unsigned file.'));
8✔
63
                }
64

65
                $seenHexSignatures = [];
6✔
66
                foreach ($contents[1] as $match) {
6✔
67
                        $signatureHex = $match[0];
6✔
68

69
                        if (isset($seenHexSignatures[$signatureHex])) {
6✔
70
                                continue;
×
71
                        }
72
                        $seenHexSignatures[$signatureHex] = true;
6✔
73

74
                        $decodedSignature = @hex2bin($signatureHex);
6✔
75
                        if ($decodedSignature === false) {
6✔
76
                                yield null;
×
77
                                continue;
×
78
                        }
79
                        yield $decodedSignature;
6✔
80
                }
81
        }
82

83
        public function setIsLibreSignFile(): void {
84
                $this->isLibreSignFile = true;
×
85
        }
86

87
        /**
88
         * @param resource $resource
89
         * @throws LibresignException When is not a signed file
90
         * @return array
91
         */
92
        #[\Override]
93
        public function getCertificateChain($resource): array {
94
                $certificates = [];
14✔
95

96
                foreach ($this->getSignatures($resource) as $signature) {
14✔
97
                        if (!$signature) {
6✔
98
                                continue;
×
99
                        }
100

101
                        $result = $this->processSignature($resource, $signature);
6✔
102

103
                        if (empty($result['chain'])) {
3✔
104
                                continue;
×
105
                        }
106

107
                        $certificates[] = $result;
3✔
108
                }
109
                return $certificates;
3✔
110
        }
111

112
        private function processSignature($resource, ?string $signature): array {
113
                $result = [];
6✔
114

115
                if (!$signature) {
6✔
116
                        $result['chain'][0]['signature_validation'] = $this->getReadableSigState('Digest Mismatch.');
×
117
                        return $result;
×
118
                }
119

120
                $decoded = ASN1::decodeBER($signature);
6✔
121
                $result = $this->extractTimestampData($decoded, $result);
6✔
122

123
                $chain = $this->extractCertificateChain($signature);
3✔
124
                if (!empty($chain)) {
3✔
125
                        $result['chain'] = $this->orderCertificates($chain);
3✔
126
                        $result = $this->enrichLeafWithPopplerData($resource, $result);
3✔
127
                }
128

129
                $result = $this->extractDocMdpData($resource, $result);
3✔
130

131
                $result = $this->applyLibreSignRootCAFlag($result);
3✔
132
                return $result;
3✔
133
        }
134

135
        private function applyLibreSignRootCAFlag(array $signer): array {
136
                if (empty($signer['chain'])) {
3✔
137
                        return $signer;
×
138
                }
139

140
                foreach ($signer['chain'] as $key => $cert) {
3✔
141
                        if ($cert['isLibreSignRootCA']
3✔
142
                                && isset($cert['certificate_validation'])
3✔
143
                                && $cert['certificate_validation']['id'] !== 1
3✔
144
                        ) {
145
                                $signer['chain'][$key]['certificate_validation'] = [
×
146
                                        'id' => 1,
×
147
                                        'label' => $this->l10n->t('Certificate is trusted.'),
×
148
                                ];
×
149
                        }
150
                }
151

152
                return $signer;
3✔
153
        }
154

155

156
        private function extractDocMdpData($resource, array $result): array {
157
                if (empty($result['chain'])) {
3✔
158
                        return $result;
×
159
                }
160

161
                $docMdpData = $this->docMdpHandler->extractDocMdpData($resource);
3✔
162
                return array_merge($result, $docMdpData);
3✔
163
        }
164

165
        private function extractTimestampData(array $decoded, array $result): array {
166
                $tsa = new TSA();
3✔
167

168
                try {
169
                        $timestampData = $tsa->extract($decoded);
3✔
170
                        if (!empty($timestampData['genTime']) || !empty($timestampData['policy']) || !empty($timestampData['serialNumber'])) {
3✔
171
                                $result['timestamp'] = $timestampData;
3✔
172
                        }
NEW
173
                } catch (\Throwable $e) {
×
174
                }
175

176
                if (!isset($result['signingTime']) || !$result['signingTime'] instanceof \DateTime) {
3✔
177
                        $result['signingTime'] = $tsa->getSigninTime($decoded);
3✔
178
                }
179
                return $result;
3✔
180
        }
181

182
        private function extractCertificateChain(string $signature): array {
183
                $pkcs7PemSignature = $this->der2pem($signature);
3✔
184
                $pemCertificates = [];
3✔
185

186
                if (!openssl_pkcs7_read($pkcs7PemSignature, $pemCertificates)) {
3✔
187
                        return [];
×
188
                }
189

190
                $chain = [];
3✔
191
                $isLibreSignRootCA = false;
3✔
192
                $certificateEngine = $this->getCertificateEngine();
3✔
193

194
                foreach ($pemCertificates as $index => $pemCertificate) {
3✔
195
                        $parsed = $certificateEngine->parseCertificate($pemCertificate);
3✔
196
                        if ($parsed) {
3✔
197
                                $parsed['signature_validation'] = [
3✔
198
                                        'id' => 1,
3✔
199
                                        'label' => $this->l10n->t('Signature is valid.'),
3✔
200
                                ];
3✔
201
                                if (!$isLibreSignRootCA) {
3✔
202
                                        $isLibreSignRootCA = $this->isLibreSignRootCA($pemCertificate, $parsed);
3✔
203
                                }
204
                                $parsed['isLibreSignRootCA'] = $isLibreSignRootCA;
3✔
205
                                $chain[$index] = $parsed;
3✔
206
                        }
207
                }
208
                if ($isLibreSignRootCA || $this->isLibreSignFile) {
3✔
209
                        foreach ($chain as &$cert) {
×
210
                                $cert['isLibreSignRootCA'] = true;
×
211
                        }
212
                }
213

214
                return $chain;
3✔
215
        }
216

217
        private function isLibreSignRootCA(string $certificate, array $parsed): bool {
218
                $crlUrls = $parsed['crl_urls'] ?? [];
3✔
219
                $rootCertificatePem = is_array($crlUrls) ? $this->crlService->getRootCertificateFromCrlUrls($crlUrls) : '';
3✔
220

221
                if (empty($rootCertificatePem)) {
3✔
222
                        $rootCertificatePem = $this->getRootCertificatePem();
3✔
223
                }
224

225
                if (empty($rootCertificatePem)) {
3✔
226
                        return false;
3✔
227
                }
228

229
                $rootFingerprint = openssl_x509_fingerprint($rootCertificatePem, 'sha256');
×
230
                $fingerprint = openssl_x509_fingerprint($certificate, 'sha256');
×
231
                if ($rootFingerprint === $fingerprint) {
×
232
                        return true;
×
233
                }
234

235
                return $this->hasLibreSignCaId($parsed);
×
236
        }
237

238
        private function hasLibreSignCaId(array $parsed): bool {
239
                $instanceId = $this->appConfig->getValueString(Application::APP_ID, 'instance_id', '');
×
240
                if (strlen($instanceId) !== 10 || !isset($parsed['subject']['OU'])) {
×
241
                        return false;
×
242
                }
243

244
                $organizationalUnits = $parsed['subject']['OU'];
×
245
                if (is_string($organizationalUnits)) {
×
246
                        $organizationalUnits = [$organizationalUnits];
×
247
                }
248

249
                foreach ($organizationalUnits as $ou) {
×
250
                        $ou = trim($ou);
×
251
                        if ($this->caIdentifierService->isValidCaId($ou, $instanceId)) {
×
252
                                return true;
×
253
                        }
254
                }
255

256
                return false;
×
257
        }
258

259
        private function getRootCertificatePem(): string {
260
                if (!empty($this->rootCertificatePem)) {
3✔
261
                        return $this->rootCertificatePem;
×
262
                }
263
                $configPath = $this->appConfig->getValueString(Application::APP_ID, 'config_path');
3✔
264
                if (empty($configPath)
3✔
265
                        || !is_dir($configPath)
×
266
                        || !is_readable($configPath . DIRECTORY_SEPARATOR . 'ca.pem')
3✔
267
                ) {
268
                        return '';
3✔
269
                }
270
                $rootCertificatePem = file_get_contents($configPath . DIRECTORY_SEPARATOR . 'ca.pem');
×
271
                if ($rootCertificatePem === false) {
×
272
                        return '';
×
273
                }
274
                $this->rootCertificatePem = $rootCertificatePem;
×
275
                return $this->rootCertificatePem;
×
276
        }
277

278
        private function enrichLeafWithPopplerData($resource, array $result): array {
279
                if (empty($result['chain'])) {
3✔
280
                        return $result;
×
281
                }
282

283
                $popplerOnlyFields = ['field', 'range', 'certificate_validation'];
3✔
284
                if (!isset($result['chain'][0]['subject'])) {
3✔
285
                        return $result;
×
286
                }
287
                $needPoppler = false;
3✔
288
                foreach ($popplerOnlyFields as $field) {
3✔
289
                        if (empty($result['chain'][0][$field])) {
3✔
290
                                $needPoppler = true;
3✔
291
                                break;
3✔
292
                        }
293
                }
294
                if (!isset($result['chain'][0]['signature_validation']) || $result['chain'][0]['signature_validation']['id'] !== 1) {
3✔
295
                        $needPoppler = true;
×
296
                }
297
                if (!$needPoppler) {
3✔
298
                        return $result;
×
299
                }
300
                $popplerChain = $this->chainFromPoppler($result['chain'][0]['subject'], $resource);
3✔
301
                if (empty($popplerChain)) {
3✔
302
                        return $result;
3✔
303
                }
304
                foreach ($popplerOnlyFields as $field) {
×
305
                        if (isset($popplerChain[$field])) {
×
306
                                $result['chain'][0][$field] = $popplerChain[$field];
×
307
                        }
308
                }
309
                if (!isset($result['chain'][0]['signature_validation']) || $result['chain'][0]['signature_validation']['id'] !== 1) {
×
310
                        if (isset($popplerChain['signature_validation'])) {
×
311
                                $result['chain'][0]['signature_validation'] = $popplerChain['signature_validation'];
×
312
                        }
313
                }
314
                return $result;
×
315
        }
316

317
        private function chainFromPoppler(array $subject, $resource): array {
318
                $fromFallback = $this->popplerUtilsPdfSignFallback($resource);
3✔
319
                foreach ($fromFallback as $popplerSig) {
3✔
320
                        if (!isset($popplerSig['chain'][0]['subject'])) {
3✔
321
                                continue;
×
322
                        }
323
                        if ($popplerSig['chain'][0]['subject'] == $subject) {
3✔
324
                                return $popplerSig['chain'][0];
×
325
                        }
326
                }
327
                return [];
3✔
328
        }
329

330
        private function popplerUtilsPdfSignFallback($resource): array {
331
                if (!empty($this->signaturesFromPoppler)) {
3✔
332
                        return $this->signaturesFromPoppler;
1✔
333
                }
334
                if (shell_exec('which pdfsig') === null) {
3✔
335
                        return $this->signaturesFromPoppler;
×
336
                }
337
                rewind($resource);
3✔
338
                $content = stream_get_contents($resource);
3✔
339
                $tempFile = $this->tempManager->getTemporaryFile('file.pdf');
3✔
340
                file_put_contents($tempFile, $content);
3✔
341

342
                $content = shell_exec('env TZ=UTC pdfsig ' . $tempFile);
3✔
343
                if (empty($content)) {
3✔
344
                        return $this->signaturesFromPoppler;
×
345
                }
346
                $lines = explode("\n", $content);
3✔
347

348
                $lastSignature = 0;
3✔
349
                foreach ($lines as $item) {
3✔
350
                        $isFirstLevel = preg_match('/^Signature\s#(\d)/', $item, $match);
3✔
351
                        if ($isFirstLevel) {
3✔
352
                                $lastSignature = (int)$match[1] - 1;
3✔
353
                                $this->signaturesFromPoppler[$lastSignature] = [];
3✔
354
                                continue;
3✔
355
                        }
356

357
                        $match = [];
3✔
358
                        $isSecondLevel = preg_match('/^\s+-\s(?<key>.+):\s(?<value>.*)/', $item, $match);
3✔
359
                        if ($isSecondLevel) {
3✔
360
                                switch ((string)$match['key']) {
3✔
361
                                        case 'Signing Time':
3✔
362
                                                $this->signaturesFromPoppler[$lastSignature]['signingTime'] = DateTime::createFromFormat('M d Y H:i:s', $match['value'], new \DateTimeZone('UTC'));
3✔
363
                                                break;
3✔
364
                                        case 'Signer full Distinguished Name':
3✔
365
                                                $this->signaturesFromPoppler[$lastSignature]['chain'][0]['subject'] = $this->parseDistinguishedNameWithMultipleValues($match['value']);
3✔
366
                                                $this->signaturesFromPoppler[$lastSignature]['chain'][0]['name'] = $match['value'];
3✔
367
                                                break;
3✔
368
                                        case 'Signing Hash Algorithm':
3✔
369
                                                $this->signaturesFromPoppler[$lastSignature]['chain'][0]['signatureTypeSN'] = $match['value'];
3✔
370
                                                break;
3✔
371
                                        case 'Signature Validation':
3✔
372
                                                $this->signaturesFromPoppler[$lastSignature]['chain'][0]['signature_validation'] = $this->getReadableSigState($match['value']);
3✔
373
                                                break;
3✔
374
                                        case 'Certificate Validation':
3✔
375
                                                $this->signaturesFromPoppler[$lastSignature]['chain'][0]['certificate_validation'] = $this->getReadableCertState($match['value']);
3✔
376
                                                break;
3✔
377
                                        case 'Signed Ranges':
3✔
378
                                                if (preg_match('/\[(\d+) - (\d+)\], \[(\d+) - (\d+)\]/', $match['value'], $ranges)) {
3✔
379
                                                        $this->signaturesFromPoppler[$lastSignature]['chain'][0]['range'] = [
3✔
380
                                                                'offset1' => (int)$ranges[1],
3✔
381
                                                                'length1' => (int)$ranges[2],
3✔
382
                                                                'offset2' => (int)$ranges[3],
3✔
383
                                                                'length2' => (int)$ranges[4],
3✔
384
                                                        ];
3✔
385
                                                }
386
                                                break;
3✔
387
                                        case 'Signature Field Name':
3✔
388
                                                $this->signaturesFromPoppler[$lastSignature]['chain'][0]['field'] = $match['value'];
3✔
389
                                                break;
3✔
390
                                        case 'Signature Validation':
3✔
391
                                        case 'Signature Type':
3✔
392
                                        case 'Total document signed':
3✔
393
                                        case 'Not total document signed':
3✔
394
                                        default:
395
                                                break;
3✔
396
                                }
397
                        }
398
                }
399
                return $this->signaturesFromPoppler;
3✔
400
        }
401

402
        private function getReadableSigState(string $status) {
403
                return match ($status) {
3✔
404
                        'Signature is Valid.' => [
3✔
405
                                'id' => 1,
3✔
406
                                'label' => $this->l10n->t('Signature is valid.'),
3✔
407
                        ],
3✔
408
                        'Signature is Invalid.' => [
×
409
                                'id' => 2,
×
410
                                'label' => $this->l10n->t('Signature is invalid.'),
×
411
                        ],
×
412
                        'Digest Mismatch.' => [
×
413
                                'id' => 3,
×
414
                                'label' => $this->l10n->t('Digest mismatch.'),
×
415
                        ],
×
416
                        "Document isn't signed or corrupted data." => [
×
417
                                'id' => 4,
×
418
                                'label' => $this->l10n->t("Document isn't signed or corrupted data."),
×
419
                        ],
×
420
                        'Signature has not yet been verified.' => [
×
421
                                'id' => 5,
×
422
                                'label' => $this->l10n->t('Signature has not yet been verified.'),
×
423
                        ],
×
424
                        default => [
3✔
425
                                'id' => 6,
3✔
426
                                'label' => $this->l10n->t('Unknown validation failure.'),
3✔
427
                        ],
3✔
428
                };
3✔
429
        }
430

431

432
        private function getReadableCertState(string $status) {
433
                return match ($status) {
3✔
434
                        'Certificate is Trusted.' => [
×
435
                                'id' => 1,
×
436
                                'label' => $this->l10n->t('Certificate is trusted.'),
×
437
                        ],
×
438
                        "Certificate issuer isn't Trusted." => [
1✔
439
                                'id' => 2,
1✔
440
                                'label' => $this->l10n->t("Certificate issuer isn't trusted."),
1✔
441
                        ],
1✔
442
                        'Certificate issuer is unknown.' => [
2✔
443
                                'id' => 3,
2✔
444
                                'label' => $this->l10n->t('Certificate issuer is unknown.'),
2✔
445
                        ],
2✔
446
                        'Certificate has been Revoked.' => [
×
447
                                'id' => 4,
×
448
                                'label' => $this->l10n->t('Certificate has been revoked.'),
×
449
                        ],
×
450
                        'Certificate has Expired' => [
×
451
                                'id' => 5,
×
452
                                'label' => $this->l10n->t('Certificate has expired'),
×
453
                        ],
×
454
                        'Certificate has not yet been verified.' => [
×
455
                                'id' => 6,
×
456
                                'label' => $this->l10n->t('Certificate has not yet been verified.'),
×
457
                        ],
×
458
                        default => [
3✔
459
                                'id' => 7,
3✔
460
                                'label' => $this->l10n->t('Unknown issue with Certificate or corrupted data.')
3✔
461
                        ],
3✔
462
                };
3✔
463
        }
464

465

466
        private function parseDistinguishedNameWithMultipleValues(string $dn): array {
467
                $result = [];
3✔
468
                $pairs = preg_split('/,(?=(?:[^"]*"[^"]*")*[^"]*$)/', $dn);
3✔
469

470
                foreach ($pairs as $pair) {
3✔
471
                        [$key, $value] = explode('=', $pair, 2);
3✔
472
                        if (empty($key) || empty($value)) {
3✔
473
                                return $result;
×
474
                        }
475
                        $key = trim($key);
3✔
476
                        $value = trim($value);
3✔
477
                        $value = trim($value, '"');
3✔
478

479
                        if (!isset($result[$key])) {
3✔
480
                                $result[$key] = $value;
3✔
481
                        } else {
482
                                if (!is_array($result[$key])) {
×
483
                                        $result[$key] = [$result[$key]];
×
484
                                }
485
                                $result[$key][] = $value;
×
486
                        }
487
                }
488

489
                return $result;
3✔
490
        }
491

492
        private function der2pem($derData) {
493
                $pem = chunk_split(base64_encode((string)$derData), 64, "\n");
3✔
494
                $pem = "-----BEGIN CERTIFICATE-----\n" . $pem . "-----END CERTIFICATE-----\n";
3✔
495
                return $pem;
3✔
496
        }
497

498
        private function getHandler(): SignEngineHandler {
499
                $sign_engine = $this->appConfig->getValueString(Application::APP_ID, 'signature_engine', 'JSignPdf');
1✔
500
                $property = lcfirst($sign_engine) . 'Handler';
1✔
501
                if (!property_exists($this, $property)) {
1✔
502
                        throw new LibresignException($this->l10n->t('Invalid Sign engine.'), 400);
×
503
                }
504
                $classHandler = 'OCA\\Libresign\\Handler\\SignEngine\\' . ucfirst($property);
1✔
505
                if (!$this->$property instanceof $classHandler) {
1✔
506
                        $this->$property = \OCP\Server::get($classHandler);
1✔
507
                }
508
                return $this->$property;
1✔
509
        }
510

511
        #[\Override]
512
        public function sign(): File {
513
                $this->beforeSign();
1✔
514

515
                $signedContent = $this->getHandler()
1✔
516
                        ->setCertificate($this->getCertificate())
1✔
517
                        ->setInputFile($this->getInputFile())
1✔
518
                        ->setPassword($this->getPassword())
1✔
519
                        ->setSignatureParams($this->getSignatureParams())
1✔
520
                        ->setVisibleElements($this->getVisibleElements())
1✔
521
                        ->getSignedContent();
1✔
522
                $this->getInputFile()->putContent($signedContent);
×
523
                return $this->getInputFile();
×
524
        }
525

526
        public function isHandlerOk(): bool {
527
                return $this->certificateEngineFactory->getEngine()->isSetupOk();
1✔
528
        }
529
}
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