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

LibreSign / libresign / 19993337372

06 Dec 2025 07:39PM UTC coverage: 42.664%. First build
19993337372

Pull #6012

github

web-flow
Merge 352bc1328 into 9de3c9f7e
Pull Request #6012: Feat/docmdp iso validation

195 of 219 new or added lines in 4 files covered. (89.04%)

5449 of 12772 relevant lines covered (42.66%)

4.75 hits per line

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

88.5
/lib/Handler/DocMdpHandler.php
1
<?php
2

3
declare(strict_types=1);
4

5
/**
6
 * SPDX-FileCopyrightText: 2025 LibreCode coop and contributors
7
 * SPDX-License-Identifier: AGPL-3.0-or-later
8
 */
9

10
namespace OCA\Libresign\Handler;
11

12
use OCA\Libresign\Db\File;
13
use OCA\Libresign\Enum\DocMdpLevel;
14
use OCP\IL10N;
15

16
class DocMdpHandler {
17
        /** @var array<string, string[]> Allowed modification types per DocMDP level */
18
        private const ALLOWED_MODIFICATIONS = [
19
                'NO_CHANGES' => [],
20
                'FORM_FILL' => ['form_field', 'template', 'signature'],
21
                'FORM_FILL_AND_ANNOTATIONS' => ['form_field', 'template', 'annotation', 'signature'],
22
        ];
23

24
        public function __construct(
25
                private IL10N $l10n,
26
        ) {
27
        }
59✔
28

29
        public function extractDocMdpData($resource): array {
30
                if (!is_resource($resource)) {
32✔
NEW
31
                        return [];
×
32
                }
33

34
                $docmdpLevel = $this->extractDocMdpLevel($resource);
32✔
35

36
                $result = [
32✔
37
                        'docmdp' => [
32✔
38
                                'level' => $docmdpLevel->value,
32✔
39
                                'label' => $docmdpLevel->getLabel($this->l10n),
32✔
40
                                'description' => $docmdpLevel->getDescription($this->l10n),
32✔
41
                                'isCertifying' => $docmdpLevel->isCertifying(),
32✔
42
                        ],
32✔
43
                ];
32✔
44

45
                $modificationInfo = $this->detectModifications($resource);
32✔
46
                $result['modifications'] = $modificationInfo;
32✔
47

48
                if ($modificationInfo['modified'] || $docmdpLevel->isCertifying()) {
32✔
49
                        $validation = $this->validateModifications($docmdpLevel, $modificationInfo, $resource);
22✔
50
                        $result['modification_validation'] = $validation;
22✔
51
                }
52

53
                return $result;
32✔
54
        }
55

56
        /**
57
         * Extract DocMDP permission level from PDF
58
         *
59
         * Validates ISO 32000-1 compliance:
60
         * - 12.8.2.2.1: Only ONE DocMDP signature allowed
61
         * - 12.8.2.2.1: DocMDP must be FIRST certifying signature
62
         * - Table 252: Signature dictionary validation (/Type /Sig, /Filter, /ByteRange)
63
         * - Table 253: Signature reference validation (/TransformMethod /DocMDP)
64
         * - Table 254: TransformParams validation (/P, /V /1.2)
65
         *
66
         * @return DocMdpLevel Permission level (NONE, NO_CHANGES, FORM_FILL, FORM_FILL_AND_ANNOTATIONS)
67
         */
68
        private function extractDocMdpLevel($pdfResource): DocMdpLevel {
69
                rewind($pdfResource);
32✔
70
                $content = stream_get_contents($pdfResource);
32✔
71

72
                if (!$this->validateIsoCompliance($content)) {
32✔
73
                        return DocMdpLevel::NONE;
9✔
74
                }
75

76
                $pValue = $this->extractPValue($content);
23✔
77
                if ($pValue === null) {
23✔
78
                        return DocMdpLevel::NONE;
3✔
79
                }
80

81
                return DocMdpLevel::tryFrom($pValue) ?? DocMdpLevel::NONE;
20✔
82
        }
83

84
        /**
85
         * Validate all ISO 32000-1 DocMDP requirements
86
         *
87
         * @return bool True if all validations pass
88
         */
89
        private function validateIsoCompliance(string $content): bool {
90
                return $this->validateSingleDocMdpSignature($content)
32✔
91
                        && $this->validateDocMdpIsFirstSignature($content)
32✔
92
                        && $this->validateSignatureDictionary($content)
32✔
93
                        && $this->validateSignatureReference($content);
32✔
94
        }
95

96
        /**
97
         * Extract /P value from TransformParams (permission level)
98
         * ISO 32000-1 Table 254: /P is optional, default 2
99
         *
100
         * @return int|null Permission value (1, 2, or 3) or null if not found/invalid
101
         */
102
        private function extractPValue(string $content): ?int {
103
                if (preg_match('/\/Reference\s*\[\s*(\d+\s+\d+\s+R)/', $content, $refMatch)) {
23✔
104
                        $pValue = $this->extractPValueFromIndirectReference($content, $refMatch[1]);
2✔
105
                        if ($pValue !== null) {
2✔
106
                                return $pValue;
1✔
107
                        }
108
                }
109

110
                $inlinePattern = '/\/Reference\s*\[\s*<<.*?\/TransformMethod\s*\/DocMDP.*?\/TransformParams\s*<<.*?\/P\s*(\d+).*?>>.*?>>.*?\]/s';
22✔
111
                if (preg_match($inlinePattern, $content, $matches)) {
22✔
112
                        if ($this->validateTransformParamsVersion($content, $matches[0])) {
21✔
113
                                return (int)$matches[1];
19✔
114
                        }
115
                }
116

117
                return null;
3✔
118
        }
119

120
        /**
121
         * Extract /P value from indirect reference structure
122
         *
123
         * @param string $content Full PDF content
124
         * @param string $indirectRef Reference like "7 0 R"
125
         * @return int|null Permission value or null
126
         */
127
        private function extractPValueFromIndirectReference(string $content, string $indirectRef): ?int {
128
                $objPattern = '/' . preg_quote($indirectRef, '/') . '.*?obj\s*<<.*?\/TransformMethod\s*\/DocMDP.*?\/TransformParams\s*(\d+\s+\d+\s+R|<<.*?\/P\s*(\d+).*?>>)/s';
2✔
129

130
                if (!preg_match($objPattern, $content, $objMatch)) {
2✔
NEW
131
                        return null;
×
132
                }
133

134
                if (isset($objMatch[2]) && is_numeric($objMatch[2])) {
2✔
NEW
135
                        if ($this->validateTransformParamsVersion($content, $objMatch[0])) {
×
NEW
136
                                return (int)$objMatch[2];
×
137
                        }
NEW
138
                        return null;
×
139
                }
140

141
                if (isset($objMatch[1]) && preg_match('/(\d+\s+\d+\s+R)/', $objMatch[1], $paramsRef)) {
2✔
142
                        $objNum = preg_replace('/\s+R$/', '', $paramsRef[1]);
2✔
143
                        $paramsPattern = '/' . preg_quote($objNum, '/') . '\s+obj\s*(<<.*?>>)\s*endobj/s';
2✔
144
                        if (preg_match($paramsPattern, $content, $paramsMatch)) {
2✔
145
                                if (preg_match('/\/P\s*(\d+)/', $paramsMatch[1], $pMatch)) {
2✔
146
                                        if ($this->validateTransformParamsVersion($content, $paramsMatch[0])) {
2✔
147
                                                return (int)$pMatch[1];
1✔
148
                                        }
149
                                }
150
                        }
151
                }
152

153
                return null;
1✔
154
        }
155

156
        /**
157
         * Parse all PDF objects (obj...endobj blocks) from content
158
         * Handles multi-line dictionaries with nested angle brackets
159
         *
160
         * @return array Array of objects with keys: objNum, dict, position
161
         */
162
        private function parsePdfObjects(string $content): array {
163
                if (!preg_match_all('/(\d+)\s+\d+\s+obj\s*(<<.*?>>)\s*endobj/s', $content, $matches, PREG_SET_ORDER | PREG_OFFSET_CAPTURE)) {
27✔
NEW
164
                        return [];
×
165
                }
166

167
                $objects = [];
27✔
168
                foreach ($matches as $match) {
27✔
169
                        $objects[] = [
27✔
170
                                'objNum' => $match[1][0],
27✔
171
                                'dict' => $match[2][0],
27✔
172
                                'position' => $match[2][1],
27✔
173
                        ];
27✔
174
                }
175
                return $objects;
27✔
176
        }
177

178
        /**
179
         * ICP-Brasil DOC-ICP-15.03: Validate /V /1.2 in TransformParams
180
         * ISO 32000-1 Table 254: /V is optional, default 1.2
181
         */
182
        private function validateTransformParamsVersion(string $content, string $context): bool {
183
                if (preg_match('/\/TransformParams\s*(\d+\s+\d+\s+R)/', $context, $paramsRef)) {
23✔
NEW
184
                        $objNum = preg_replace('/\s+R$/', '', $paramsRef[1]);
×
NEW
185
                        $paramsPattern = '/' . preg_quote($objNum, '/') . '\s+obj\s*(<<.*?>>)\s*endobj/s';
×
NEW
186
                        if (preg_match($paramsPattern, $content, $objMatch)) {
×
NEW
187
                                return preg_match('/\/V\s*\/1\.2/', $objMatch[1]) === 1;
×
188
                        }
NEW
189
                        return false;
×
190
                }
191
                return preg_match('/\/V\s*\/1\.2/', $context) === 1;
23✔
192
        }
193

194
        private function detectModifications($pdfResource): array {
195
                rewind($pdfResource);
32✔
196
                $content = stream_get_contents($pdfResource);
32✔
197
                $fileSize = strlen($content);
32✔
198

199
                preg_match_all(
32✔
200
                        '/ByteRange\s*\[\s*(?<offset1>\d+)\s+(?<length1>\d+)\s+(?<offset2>\d+)\s+(?<length2>\d+)\s*\]/',
32✔
201
                        $content,
32✔
202
                        $byteRanges,
32✔
203
                        PREG_SET_ORDER
32✔
204
                );
32✔
205

206
                if (empty($byteRanges)) {
32✔
207
                        return [
4✔
208
                                'modified' => false,
4✔
209
                                'revisionCount' => 0,
4✔
210
                                'details' => [],
4✔
211
                        ];
4✔
212
                }
213

214
                $modifications = [];
28✔
215
                foreach ($byteRanges as $index => $range) {
28✔
216
                        $coveredEnd = (int)$range['offset2'] + (int)$range['length2'];
28✔
217
                        $hasModifications = $coveredEnd < $fileSize;
28✔
218

219
                        $modifications[] = [
28✔
220
                                'signatureIndex' => $index,
28✔
221
                                'modified' => $hasModifications,
28✔
222
                                'coveredBytes' => $coveredEnd,
28✔
223
                                'totalBytes' => $fileSize,
28✔
224
                                'extraBytes' => $hasModifications ? ($fileSize - $coveredEnd) : 0,
28✔
225
                        ];
28✔
226
                }
227

228
                $isModified = array_reduce($modifications, fn ($carry, $item) => $carry || $item['modified'], false);
28✔
229

230
                return [
28✔
231
                        'modified' => $isModified,
28✔
232
                        'revisionCount' => count($byteRanges),
28✔
233
                        'details' => $modifications,
28✔
234
                ];
28✔
235
        }
236

237
        /**
238
         * Validate if modifications are allowed by DocMDP level
239
         * ISO 32000-1 Table 254: P=1 (no changes), P=2 (form fill), P=3 (form fill + annotations)
240
         *
241
         * @return array Validation result with keys: valid, status, message
242
         */
243
        private function validateModifications(DocMdpLevel $docmdpLevel, array $modificationInfo, $pdfResource): array {
244
                if (!$modificationInfo['modified']) {
22✔
245
                        return $this->buildValidationResult(
6✔
246
                                true,
6✔
247
                                File::MODIFICATION_UNMODIFIED,
6✔
248
                                'Document has not been modified after signing'
6✔
249
                        );
6✔
250
                }
251

252
                if ($docmdpLevel === DocMdpLevel::NONE) {
16✔
253
                        return $this->buildValidationResult(
4✔
254
                                true,
4✔
255
                                File::MODIFICATION_ALLOWED,
4✔
256
                                'Document was modified after signing'
4✔
257
                        );
4✔
258
                }
259

260
                $modificationType = $this->analyzeModificationType($pdfResource, $modificationInfo);
12✔
261
                $allowedTypes = self::ALLOWED_MODIFICATIONS[$docmdpLevel->name] ?? null;
12✔
262

263
                if ($allowedTypes === null) {
12✔
NEW
264
                        return $this->buildValidationResult(
×
NEW
265
                                false,
×
NEW
266
                                File::MODIFICATION_VIOLATION,
×
NEW
267
                                'Invalid: Document was modified after signing (DocMDP violation)'
×
NEW
268
                        );
×
269
                }
270

271
                $isAllowed = in_array($modificationType, $allowedTypes, true);
12✔
272

273
                return $isAllowed
12✔
274
                        ? $this->buildValidationResult(
7✔
275
                                true,
7✔
276
                                File::MODIFICATION_ALLOWED,
7✔
277
                                $this->getAllowedModificationMessage($docmdpLevel)
7✔
278
                        )
7✔
279
                        : $this->buildValidationResult(
12✔
280
                                false,
12✔
281
                                File::MODIFICATION_VIOLATION,
12✔
282
                                $this->getViolationMessage($docmdpLevel)
12✔
283
                        );
12✔
284
        }
285

286
        /**
287
         * Build validation result array
288
         *
289
         * @param bool $valid Whether modification is valid
290
         * @param int $status Status constant from File class
291
         * @param string $messageKey Translation key
292
         * @return array Validation result
293
         */
294
        private function buildValidationResult(bool $valid, int $status, string $messageKey): array {
295
                return [
22✔
296
                        'valid' => $valid,
22✔
297
                        'status' => $status,
22✔
298
                        'message' => $this->l10n->t($messageKey),
22✔
299
                ];
22✔
300
        }
301

302
        /**
303
         * Get success message for allowed modification
304
         *
305
         * @param DocMdpLevel $level DocMDP permission level
306
         * @return string Translated message
307
         */
308
        private function getAllowedModificationMessage(DocMdpLevel $level): string {
309
                return match ($level) {
310
                        DocMdpLevel::NO_CHANGES => 'Invalid: Document was modified after signing (DocMDP violation - no changes allowed)',
7✔
311
                        DocMdpLevel::FORM_FILL => 'Document form fields were modified (allowed by DocMDP P=2)',
7✔
312
                        DocMdpLevel::FORM_FILL_AND_ANNOTATIONS => 'Document form fields or annotations were modified (allowed by DocMDP P=3)',
4✔
313
                        default => 'Document was modified after signing',
7✔
314
                };
315
        }
316

317
        /**
318
         * Get error message for modification violation
319
         *
320
         * @param DocMdpLevel $level DocMDP permission level
321
         * @return string Translated message
322
         */
323
        private function getViolationMessage(DocMdpLevel $level): string {
324
                return match ($level) {
325
                        DocMdpLevel::NO_CHANGES => 'Invalid: Document was modified after signing (DocMDP violation - no changes allowed)',
5✔
326
                        DocMdpLevel::FORM_FILL => 'Invalid: Document was modified after signing (DocMDP P=2 only allows form field changes)',
2✔
327
                        DocMdpLevel::FORM_FILL_AND_ANNOTATIONS => 'Invalid: Document was modified after signing (DocMDP P=3 only allows form fields and annotations)',
1✔
328
                        default => 'Invalid: Document was modified after signing (DocMDP violation)',
5✔
329
                };
330
        }
331

332
        /**
333
         * Analyze type of modification made to PDF after signing
334
         *
335
         * Patterns are checked in priority order (most specific first) to ensure
336
         * accurate classification when multiple patterns could match.
337
         *
338
         * @param resource $pdfResource PDF file resource
339
         * @param array $modificationInfo Modification detection info
340
         * @return string Modification type: signature, form_field, template, annotation, structural, unknown
341
         */
342
        private function analyzeModificationType($pdfResource, array $modificationInfo): string {
343
                if (empty($modificationInfo['details'])) {
12✔
NEW
344
                        return 'unknown';
×
345
                }
346

347
                rewind($pdfResource);
12✔
348
                $content = stream_get_contents($pdfResource);
12✔
349
                $coveredEnd = $modificationInfo['details'][0]['coveredBytes'];
12✔
350
                $modifiedContent = substr($content, $coveredEnd);
12✔
351

352
                $patterns = [
12✔
353
                        'signature' => '/\/Type\s*\/Sig/',
12✔
354
                        'form_field' => '/\/FT\s*\/(?:Tx|Ch|Btn)/',
12✔
355
                        'template' => '/\/Type\s*\/XObject\s*\/Subtype\s*\/Form/',
12✔
356
                        'annotation' => '/\/Type\s*\/Annot/',
12✔
357
                        'structural' => '/\/Type\s*\/Pages?/',
12✔
358
                ];
12✔
359

360
                foreach ($patterns as $type => $pattern) {
12✔
361
                        if (preg_match($pattern, $modifiedContent)) {
12✔
362
                                return $type;
12✔
363
                        }
364
                }
365

NEW
366
                return 'unknown';
×
367
        }
368

369
        /**
370
         * ISO 32000-1 12.8.2.2.1: A document can contain only one signature field that contains a DocMDP transform method
371
         */
372
        private function validateSingleDocMdpSignature(string $content): bool {
373
                $docmdpCount = preg_match_all('/\/TransformMethod\s*\/DocMDP/', $content);
32✔
374
                return $docmdpCount === 1;
32✔
375
        }
376

377
        /**
378
         * ISO 32000-1 12.8.2.2.1: DocMDP shall be the first signed field
379
         *
380
         * "First signed field" means first CERTIFYING signature (has /Reference)
381
         * that has been applied (/Contents present). Approval signatures (without
382
         * /Reference) don't count as they cannot have DocMDP.
383
         *
384
         * @return bool True if DocMDP is in first certifying signature
385
         */
386
        private function validateDocMdpIsFirstSignature(string $content): bool {
387
                $certifyingSignatures = $this->filterCertifyingSignatures($this->parsePdfObjects($content));
27✔
388

389
                if (empty($certifyingSignatures)) {
27✔
390
                        return false;
4✔
391
                }
392

393
                usort($certifyingSignatures, fn ($a, $b) => $a['position'] <=> $b['position']);
23✔
394

395
                return $this->signatureHasDocMdp($content, $certifyingSignatures[0]['dict']);
23✔
396
        }
397

398
        /**
399
         * Filter only certifying signatures from parsed objects
400
         *
401
         * @param array $objects Parsed PDF objects
402
         * @return array Certifying signatures with /Filter, /ByteRange, /Contents, /Reference
403
         */
404
        private function filterCertifyingSignatures(array $objects): array {
405
                return array_filter($objects, function ($obj) {
27✔
406
                        $dict = $obj['dict'];
27✔
407
                        return preg_match('/\/Filter\s*\//', $dict)
27✔
408
                                && preg_match('/\/ByteRange\s*\[/', $dict)
27✔
409
                                && preg_match('/\/Contents\s*</', $dict)
27✔
410
                                && preg_match('/\/Reference\s*\[/', $dict);
27✔
411
                });
27✔
412
        }
413

414
        /**
415
         * Check if signature dictionary has DocMDP (inline or indirect)
416
         *
417
         * @param string $content Full PDF content
418
         * @param string $dict Signature dictionary
419
         * @return bool True if has DocMDP
420
         */
421
        private function signatureHasDocMdp(string $content, string $dict): bool {
422
                if (preg_match('/\/Reference\s*\[.*?\/TransformMethod\s*\/DocMDP/s', $dict)) {
23✔
423
                        return true;
21✔
424
                }
425

426
                if (preg_match('/\/Reference\s*\[\s*(\d+)\s+\d+\s+R/', $dict, $refMatch)) {
2✔
427
                        $refPattern = '/' . $refMatch[1] . '\s+\d+\s+obj\s*<<.*?\/TransformMethod\s*\/DocMDP.*?>>.*?endobj/s';
2✔
428
                        return (bool)preg_match($refPattern, $content);
2✔
429
                }
430

NEW
431
                return false;
×
432
        }
433

434
        /**
435
         * ISO 32000-1 Table 252: Validate signature dictionary entries
436
         *
437
         * Required entries:
438
         * - /Type /Sig (optional, but if present must be /Sig)
439
         * - /Filter (Required) - signature handler name
440
         * - /ByteRange (Required for DocMDP) - byte ranges covered by signature
441
         *
442
         * @return bool True if signature dictionary is valid
443
         */
444
        private function validateSignatureDictionary(string $content): bool {
445
                $objects = $this->parsePdfObjects($content);
23✔
446
                $sigDict = $this->findSignatureDictionary($objects);
23✔
447

448
                if (!$sigDict) {
23✔
NEW
449
                        return false;
×
450
                }
451

452
                return $this->validateDictionaryEntries($sigDict);
23✔
453
        }
454

455
        /**
456
         * Find signature dictionary with /Reference entry
457
         *
458
         * @param array $objects Parsed PDF objects
459
         * @return string|null Dictionary content or null
460
         */
461
        private function findSignatureDictionary(array $objects): ?string {
462
                foreach ($objects as $obj) {
23✔
463
                        if (preg_match('/\/Reference\s*\[/', $obj['dict'])) {
23✔
464
                                return $obj['dict'];
23✔
465
                        }
466
                }
NEW
467
                return null;
×
468
        }
469

470
        /**
471
         * Validate signature dictionary entries per ISO Table 252
472
         *
473
         * @param string $dict Dictionary content
474
         * @return bool True if all required entries are valid
475
         */
476
        private function validateDictionaryEntries(string $dict): bool {
477
                if (preg_match('/\/Type\s*\/(\w+)/', $dict, $typeMatch) && $typeMatch[1] !== 'Sig') {
23✔
NEW
478
                        return false;
×
479
                }
480

481
                if (!preg_match('/\/Filter\s*\/[\w.]+/', $dict)) {
23✔
NEW
482
                        return false;
×
483
                }
484

485
                return (bool)preg_match('/\/ByteRange\s*\[/', $dict);
23✔
486
        }
487

488
        /**
489
         * ISO 32000-1 Table 253: Validate signature reference dictionary
490
         *
491
         * @return bool True if /TransformMethod /DocMDP is present (inline or indirect)
492
         */
493
        private function validateSignatureReference(string $content): bool {
494
                return (bool)preg_match('/\/TransformMethod\s*\/DocMDP/', $content);
23✔
495
        }
496
}
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