Code Coverage
 
Lines
Functions and Methods
Classes and Traits
Total
91.77% covered (success)
91.77%
223 / 243
73.08% covered (warning)
73.08%
19 / 26
CRAP
0.00% covered (danger)
0.00%
0 / 1
DocMdpHandler
91.77% covered (success)
91.77%
223 / 243
73.08% covered (warning)
73.08%
19 / 26
96.72
0.00% covered (danger)
0.00%
0 / 1
 __construct
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 allowsAdditionalSignatures
100.00% covered (success)
100.00%
2 / 2
100.00% covered (success)
100.00%
1 / 1
1
 extractDocMdpData
96.15% covered (success)
96.15%
25 / 26
0.00% covered (danger)
0.00%
0 / 1
6
 extractDocMdpLevel
100.00% covered (success)
100.00%
9 / 9
100.00% covered (success)
100.00%
1 / 1
4
 validateIsoCompliance
0.00% covered (danger)
0.00%
0 / 4
0.00% covered (danger)
0.00%
0 / 1
20
 getIsoComplianceStatus
100.00% covered (success)
100.00%
6 / 6
100.00% covered (success)
100.00%
1 / 1
1
 isIsoCompliant
100.00% covered (success)
100.00%
4 / 4
100.00% covered (success)
100.00%
1 / 1
4
 isOnlySingleDocMdpViolation
100.00% covered (success)
100.00%
4 / 4
100.00% covered (success)
100.00%
1 / 1
4
 extractPValue
100.00% covered (success)
100.00%
9 / 9
100.00% covered (success)
100.00%
1 / 1
5
 extractPValueFromIndirectReference
86.67% covered (warning)
86.67%
13 / 15
0.00% covered (danger)
0.00%
0 / 1
10.24
 parsePdfObjects
100.00% covered (success)
100.00%
10 / 10
100.00% covered (success)
100.00%
1 / 1
3
 validateTransformParamsVersion
28.57% covered (danger)
28.57%
2 / 7
0.00% covered (danger)
0.00%
0 / 1
6.28
 detectModifications
100.00% covered (success)
100.00%
32 / 32
100.00% covered (success)
100.00%
1 / 1
5
 validateModifications
84.38% covered (warning)
84.38%
27 / 32
0.00% covered (danger)
0.00%
0 / 1
5.10
 buildValidationResult
100.00% covered (success)
100.00%
5 / 5
100.00% covered (success)
100.00%
1 / 1
1
 getAllowedModificationMessage
100.00% covered (success)
100.00%
4 / 4
100.00% covered (success)
100.00%
1 / 1
1
 getViolationMessage
100.00% covered (success)
100.00%
4 / 4
100.00% covered (success)
100.00%
1 / 1
1
 analyzeModificationType
88.24% covered (warning)
88.24%
15 / 17
0.00% covered (danger)
0.00%
0 / 1
4.03
 validateSingleDocMdpSignature
100.00% covered (success)
100.00%
18 / 18
100.00% covered (success)
100.00%
1 / 1
10
 validateDocMdpIsFirstSignature
100.00% covered (success)
100.00%
5 / 5
100.00% covered (success)
100.00%
1 / 1
2
 filterCertifyingSignatures
100.00% covered (success)
100.00%
7 / 7
100.00% covered (success)
100.00%
1 / 1
4
 signatureHasDocMdp
100.00% covered (success)
100.00%
6 / 6
100.00% covered (success)
100.00%
1 / 1
3
 validateSignatureDictionary
100.00% covered (success)
100.00%
5 / 5
100.00% covered (success)
100.00%
1 / 1
2
 findSignatureDictionary
100.00% covered (success)
100.00%
5 / 5
100.00% covered (success)
100.00%
1 / 1
4
 validateDictionaryEntries
80.00% covered (warning)
80.00%
4 / 5
0.00% covered (danger)
0.00%
0 / 1
3.07
 validateSignatureReference
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
1<?php
2
3declare(strict_types=1);
4
5/**
6 * SPDX-FileCopyrightText: 2025 LibreCode coop and contributors
7 * SPDX-License-Identifier: AGPL-3.0-or-later
8 */
9
10namespace OCA\Libresign\Handler;
11
12use OCA\Libresign\Db\File;
13use OCA\Libresign\Enum\DocMdpLevel;
14use OCP\IL10N;
15
16class DocMdpHandler {
17    /** @var array<string, string[]> Allowed modification types per DocMDP level */
18    private const array ALLOWED_MODIFICATIONS = [
19        'CERTIFIED_NO_CHANGES_ALLOWED' => [],
20        'CERTIFIED_FORM_FILLING' => ['form_field', 'template', 'signature'],
21        'CERTIFIED_FORM_FILLING_AND_ANNOTATIONS' => ['form_field', 'template', 'annotation', 'signature'],
22    ];
23
24    public function __construct(
25        private IL10N $l10n,
26    ) {
27    }
28
29    public function allowsAdditionalSignatures($resource): bool {
30        $docmdpLevel = $this->extractDocMdpLevel($resource);
31
32        return $docmdpLevel !== DocMdpLevel::CERTIFIED_NO_CHANGES_ALLOWED;
33    }
34
35    public function extractDocMdpData($resource): array {
36        if (!is_resource($resource)) {
37            return [];
38        }
39
40        rewind($resource);
41        $content = stream_get_contents($resource);
42        $isoStatus = $this->getIsoComplianceStatus($content);
43
44        rewind($resource);
45        $docmdpLevel = $this->extractDocMdpLevel($resource);
46
47        $result = [
48            'docmdp' => [
49                'level' => $docmdpLevel->value,
50                'label' => $docmdpLevel->getLabel($this->l10n),
51                'description' => $docmdpLevel->getDescription($this->l10n),
52                'isCertifying' => $docmdpLevel->isCertifying(),
53            ],
54        ];
55        if ($docmdpLevel->isCertifying() && $this->isOnlySingleDocMdpViolation($isoStatus)) {
56            $result['docmdp_validation'] = [
57                'valid' => false,
58                // TRANSLATORS: Validation message shown for a PDF with more than one DocMDP (document modification policy) certifying signature. Only the first certifying signature defines the document permission level.
59                'message' => $this->l10n->t('Multiple DocMDP signatures detected. The first certifying signature determines the document\'s permission level.'),
60            ];
61        }
62
63        $modificationInfo = $this->detectModifications($resource);
64        $result['modifications'] = $modificationInfo;
65
66        if ($modificationInfo['modified'] || $docmdpLevel->isCertifying()) {
67            $validation = $this->validateModifications($docmdpLevel, $modificationInfo, $resource);
68            $result['modification_validation'] = $validation;
69        }
70
71        return $result;
72    }
73
74    /**
75     * Extract DocMDP permission level from PDF
76     *
77     * Validates ISO 32000-1 compliance:
78     * - 12.8.2.2.1: Only ONE DocMDP signature allowed
79     * - 12.8.2.2.1: DocMDP must be FIRST certifying signature
80     * - Table 252: Signature dictionary validation (/Type /Sig, /Filter, /ByteRange)
81     * - Table 253: Signature reference validation (/TransformMethod /DocMDP)
82     * - Table 254: TransformParams validation (/P, /V /1.2)
83     *
84     * @return DocMdpLevel Permission level (NONE, NO_CHANGES, FORM_FILL, FORM_FILL_AND_ANNOTATIONS)
85     */
86    private function extractDocMdpLevel($pdfResource): DocMdpLevel {
87        rewind($pdfResource);
88        $content = stream_get_contents($pdfResource);
89
90        $isoStatus = $this->getIsoComplianceStatus($content);
91        if (!$this->isIsoCompliant($isoStatus) && !$this->isOnlySingleDocMdpViolation($isoStatus)) {
92            return DocMdpLevel::NOT_CERTIFIED;
93        }
94
95        $pValue = $this->extractPValue($content);
96        if ($pValue === null) {
97            return DocMdpLevel::NOT_CERTIFIED;
98        }
99
100        return DocMdpLevel::tryFrom($pValue) ?? DocMdpLevel::NOT_CERTIFIED;
101    }
102
103    /**
104     * Validate all ISO 32000-1 DocMDP requirements
105     *
106     * @return bool True if all validations pass
107     */
108    private function validateIsoCompliance(string $content): bool {
109        return $this->validateSingleDocMdpSignature($content)
110            && $this->validateDocMdpIsFirstSignature($content)
111            && $this->validateSignatureDictionary($content)
112            && $this->validateSignatureReference($content);
113    }
114
115    private function getIsoComplianceStatus(string $content): array {
116        return [
117            'single_docmdp' => $this->validateSingleDocMdpSignature($content),
118            'docmdp_first' => $this->validateDocMdpIsFirstSignature($content),
119            'sig_dict' => $this->validateSignatureDictionary($content),
120            'sig_ref' => $this->validateSignatureReference($content),
121        ];
122    }
123
124    private function isIsoCompliant(array $status): bool {
125        return $status['single_docmdp']
126            && $status['docmdp_first']
127            && $status['sig_dict']
128            && $status['sig_ref'];
129    }
130
131    private function isOnlySingleDocMdpViolation(array $status): bool {
132        return !$status['single_docmdp']
133            && $status['docmdp_first']
134            && $status['sig_dict']
135            && $status['sig_ref'];
136    }
137
138    /**
139     * Extract /P value from TransformParams (permission level)
140     * ISO 32000-1 Table 254: /P is optional, default 2
141     *
142     * @return int|null Permission value (1, 2, or 3) or null if not found/invalid
143     */
144    private function extractPValue(string $content): ?int {
145        if (preg_match('/\/Reference\s*\[\s*(\d+\s+\d+\s+R)/', $content, $refMatch)) {
146            $pValue = $this->extractPValueFromIndirectReference($content, $refMatch[1]);
147            if ($pValue !== null) {
148                return $pValue;
149            }
150        }
151
152        $inlinePattern = '/\/Reference\s*\[\s*<<.*?\/TransformMethod\s*\/DocMDP.*?\/TransformParams\s*<<.*?\/P\s*(\d+).*?>>.*?>>.*?\]/s';
153        if (preg_match($inlinePattern, $content, $matches)) {
154            if ($this->validateTransformParamsVersion($content, $matches[0])) {
155                return (int)$matches[1];
156            }
157        }
158
159        return null;
160    }
161
162    /**
163     * Extract /P value from indirect reference structure
164     *
165     * @param string $content Full PDF content
166     * @param string $indirectRef Reference like "7 0 R"
167     * @return int|null Permission value or null
168     */
169    private function extractPValueFromIndirectReference(string $content, string $indirectRef): ?int {
170        $objPattern = '/' . preg_quote($indirectRef, '/') . '.*?obj\s*<<.*?\/TransformMethod\s*\/DocMDP.*?\/TransformParams\s*(\d+\s+\d+\s+R|<<.*?\/P\s*(\d+).*?>>)/s';
171
172        if (!preg_match($objPattern, $content, $objMatch)) {
173            return null;
174        }
175
176        if (isset($objMatch[2]) && is_numeric($objMatch[2])) {
177            if ($this->validateTransformParamsVersion($content, $objMatch[0])) {
178                return (int)$objMatch[2];
179            }
180            return null;
181        }
182
183        if (isset($objMatch[1]) && preg_match('/(\d+\s+\d+\s+R)/', $objMatch[1], $paramsRef)) {
184            $objNum = preg_replace('/\s+R$/', '', $paramsRef[1]);
185            $paramsPattern = '/' . preg_quote((string)$objNum, '/') . '\s+obj\s*(<<.*?>>)\s*endobj/s';
186            if (preg_match($paramsPattern, $content, $paramsMatch)) {
187                if (preg_match('/\/P\s*(\d+)/', $paramsMatch[1], $pMatch)) {
188                    if ($this->validateTransformParamsVersion($content, $paramsMatch[0])) {
189                        return (int)$pMatch[1];
190                    }
191                }
192            }
193        }
194
195        return null;
196    }
197
198    /**
199     * Parse all PDF objects (obj...endobj blocks) from content
200     * Handles multi-line dictionaries with nested angle brackets
201     *
202     * @return array Array of objects with keys: objNum, dict, position
203     */
204    private function parsePdfObjects(string $content): array {
205        if (!preg_match_all('/(\d+)\s+\d+\s+obj(.*?)endobj/s', $content, $matches, PREG_SET_ORDER | PREG_OFFSET_CAPTURE)) {
206            return [];
207        }
208
209        $objects = [];
210        foreach ($matches as $match) {
211            $objects[] = [
212                'objNum' => $match[1][0],
213                'dict' => trim($match[2][0]),
214                'position' => $match[2][1],
215            ];
216        }
217        return $objects;
218    }
219
220    /**
221     * ICP-Brasil DOC-ICP-15.03: Validate /V /1.2 in TransformParams
222     * ISO 32000-1 Table 254: /V is optional, default 1.2
223     */
224    private function validateTransformParamsVersion(string $content, string $context): bool {
225        if (preg_match('/\/TransformParams\s*(\d+\s+\d+\s+R)/', $context, $paramsRef)) {
226            $objNum = preg_replace('/\s+R$/', '', $paramsRef[1]);
227            $paramsPattern = '/' . preg_quote((string)$objNum, '/') . '\s+obj\s*(<<.*?>>)\s*endobj/s';
228            if (preg_match($paramsPattern, $content, $objMatch)) {
229                return preg_match('/\/V\s*\/1\.2/', $objMatch[1]) === 1;
230            }
231            return false;
232        }
233        return preg_match('/\/V\s*\/1\.2/', $context) === 1;
234    }
235
236    private function detectModifications($pdfResource): array {
237        rewind($pdfResource);
238        $content = stream_get_contents($pdfResource);
239        $fileSize = strlen($content);
240
241        preg_match_all(
242            '/ByteRange\s*\[\s*(?<offset1>\d+)\s+(?<length1>\d+)\s+(?<offset2>\d+)\s+(?<length2>\d+)\s*\]/',
243            $content,
244            $byteRanges,
245            PREG_SET_ORDER
246        );
247
248        if (empty($byteRanges)) {
249            return [
250                'modified' => false,
251                'revisionCount' => 0,
252                'details' => [],
253            ];
254        }
255
256        $modifications = [];
257        foreach ($byteRanges as $index => $range) {
258            $coveredEnd = (int)$range['offset2'] + (int)$range['length2'];
259            $hasModifications = $coveredEnd < $fileSize;
260
261            $modifications[] = [
262                'signatureIndex' => $index,
263                'modified' => $hasModifications,
264                'coveredBytes' => $coveredEnd,
265                'totalBytes' => $fileSize,
266                'extraBytes' => $hasModifications ? ($fileSize - $coveredEnd) : 0,
267            ];
268        }
269
270        $isModified = array_reduce($modifications, fn ($carry, $item) => $carry || $item['modified'], false);
271
272        return [
273            'modified' => $isModified,
274            'revisionCount' => count($byteRanges),
275            'details' => $modifications,
276        ];
277    }
278
279    /**
280     * Validate if modifications are allowed by DocMDP level
281     * ISO 32000-1 Table 254: P=1 (no changes), P=2 (form fill), P=3 (form fill + annotations)
282     *
283     * @return array Validation result with keys: valid, status, message
284     */
285    private function validateModifications(DocMdpLevel $docmdpLevel, array $modificationInfo, $pdfResource): array {
286        if (!$modificationInfo['modified']) {
287            // TRANSLATORS: Validation result for a signed PDF that was not changed after the signature was applied.
288            return $this->buildValidationResult(
289                true,
290                File::MODIFICATION_UNMODIFIED,
291                $this->l10n->t('Document has not been modified after signing')
292            );
293        }
294
295        if ($docmdpLevel === DocMdpLevel::NOT_CERTIFIED) {
296            // TRANSLATORS: Validation result for a signed PDF that was modified, but the document has no DocMDP certification restrictions.
297            return $this->buildValidationResult(
298                true,
299                File::MODIFICATION_ALLOWED,
300                $this->l10n->t('Document was modified after signing')
301            );
302        }
303
304        $modificationType = $this->analyzeModificationType($pdfResource, $modificationInfo);
305        $allowedTypes = self::ALLOWED_MODIFICATIONS[$docmdpLevel->name] ?? null;
306
307        if ($allowedTypes === null) {
308            // TRANSLATORS: Validation error for a signed PDF modified in a way that violates its DocMDP (document modification policy) certification rules.
309            return $this->buildValidationResult(
310                false,
311                File::MODIFICATION_VIOLATION,
312                $this->l10n->t('Invalid: Document was modified after signing (DocMDP violation)')
313            );
314        }
315
316        $isAllowed = in_array($modificationType, $allowedTypes, true);
317
318        return $isAllowed
319            ? $this->buildValidationResult(
320                true,
321                File::MODIFICATION_ALLOWED,
322                $this->getAllowedModificationMessage($docmdpLevel)
323            )
324            : $this->buildValidationResult(
325                false,
326                File::MODIFICATION_VIOLATION,
327                $this->getViolationMessage($docmdpLevel)
328            );
329    }
330
331    /**
332     * Build validation result array
333     *
334     * @param bool $valid Whether modification is valid
335     * @param int $status Status constant from File class
336     * @param string $message Translated message
337     * @return array Validation result
338     */
339    private function buildValidationResult(bool $valid, int $status, string $message): array {
340        return [
341            'valid' => $valid,
342            'status' => $status,
343            'message' => $message,
344        ];
345    }
346
347    /**
348     * Get success message for allowed modification
349     *
350     * @param DocMdpLevel $level DocMDP permission level
351     * @return string Translated message
352     */
353    private function getAllowedModificationMessage(DocMdpLevel $level): string {
354        return match ($level) {
355            // TRANSLATORS: Validation error for a certified PDF with DocMDP level P=1, which does not allow any change after signing.
356            DocMdpLevel::CERTIFIED_NO_CHANGES_ALLOWED => $this->l10n->t('Invalid: Document was modified after signing (DocMDP violation - no changes allowed)'),
357            // TRANSLATORS: Validation result for a certified PDF with DocMDP level P=2. Only form field completion is allowed after signing.
358            DocMdpLevel::CERTIFIED_FORM_FILLING => $this->l10n->t('Document form fields were modified (allowed by DocMDP P=2)'),
359            // TRANSLATORS: Validation result for a certified PDF with DocMDP level P=3. Form field completion and annotations are allowed after signing.
360            DocMdpLevel::CERTIFIED_FORM_FILLING_AND_ANNOTATIONS => $this->l10n->t('Document form fields or annotations were modified (allowed by DocMDP P=3)'),
361            // TRANSLATORS: Generic validation result for a signed PDF that was modified after signing.
362            default => $this->l10n->t('Document was modified after signing'),
363        };
364    }
365
366    /**
367     * Get error message for modification violation
368     *
369     * @param DocMdpLevel $level DocMDP permission level
370     * @return string Translated message
371     */
372    private function getViolationMessage(DocMdpLevel $level): string {
373        return match ($level) {
374            // TRANSLATORS: Validation error for a certified PDF with DocMDP level P=1, which does not allow any change after signing.
375            DocMdpLevel::CERTIFIED_NO_CHANGES_ALLOWED => $this->l10n->t('Invalid: Document was modified after signing (DocMDP violation - no changes allowed)'),
376            // TRANSLATORS: Validation error for a certified PDF with DocMDP level P=2. Only form field completion is allowed after signing.
377            DocMdpLevel::CERTIFIED_FORM_FILLING => $this->l10n->t('Invalid: Document was modified after signing (DocMDP P=2 only allows form field changes)'),
378            // TRANSLATORS: Validation error for a certified PDF with DocMDP level P=3. Only form field completion and annotations are allowed after signing.
379            DocMdpLevel::CERTIFIED_FORM_FILLING_AND_ANNOTATIONS => $this->l10n->t('Invalid: Document was modified after signing (DocMDP P=3 only allows form fields and annotations)'),
380            // TRANSLATORS: Generic validation error for a signed PDF modified in a way that violates DocMDP (document modification policy) certification rules.
381            default => $this->l10n->t('Invalid: Document was modified after signing (DocMDP violation)'),
382        };
383    }
384
385    /**
386     * Analyze type of modification made to PDF after signing
387     *
388     * Patterns are checked in priority order (most specific first) to ensure
389     * accurate classification when multiple patterns could match.
390     *
391     * @param resource $pdfResource PDF file resource
392     * @param array $modificationInfo Modification detection info
393     * @return string Modification type: signature, form_field, template, annotation, structural, unknown
394     */
395    private function analyzeModificationType($pdfResource, array $modificationInfo): string {
396        if (empty($modificationInfo['details'])) {
397            return 'unknown';
398        }
399
400        rewind($pdfResource);
401        $content = stream_get_contents($pdfResource);
402        $coveredEnd = $modificationInfo['details'][0]['coveredBytes'];
403        $modifiedContent = substr($content, $coveredEnd);
404
405        $patterns = [
406            'signature' => '/\/Type\s*\/Sig/',
407            'form_field' => '/\/FT\s*\/(?:Tx|Ch|Btn)/',
408            'template' => '/\/Type\s*\/XObject\s*\/Subtype\s*\/Form/',
409            'annotation' => '/\/Type\s*\/Annot/',
410            'structural' => '/\/Type\s*\/Pages?/',
411        ];
412
413        foreach ($patterns as $type => $pattern) {
414            if (preg_match($pattern, $modifiedContent)) {
415                return $type;
416            }
417        }
418
419        return 'unknown';
420    }
421
422    /**
423     * ISO 32000-1 12.8.2.2.1: A document can contain only one signature field that contains a DocMDP transform method
424     */
425    private function validateSingleDocMdpSignature(string $content): bool {
426        $objects = $this->parsePdfObjects($content);
427        if (empty($objects)) {
428            return false;
429        }
430
431        $latestByObj = [];
432        foreach ($objects as $obj) {
433            $objNum = $obj['objNum'];
434            if (!isset($latestByObj[$objNum]) || $obj['position'] > $latestByObj[$objNum]['position']) {
435                $latestByObj[$objNum] = $obj;
436            }
437        }
438
439        $docMdpSignatureCount = 0;
440        foreach ($latestByObj as $obj) {
441            $dict = $obj['dict'];
442            if (!preg_match('/\/Type\s*\/Sig\b/', (string)$dict) || !preg_match('/\/Reference\s*\[/', (string)$dict)) {
443                continue;
444            }
445            if ($this->signatureHasDocMdp($content, $dict)) {
446                $docMdpSignatureCount++;
447                if ($docMdpSignatureCount > 1) {
448                    return false;
449                }
450            }
451        }
452
453        // ISO 32000-1: exactly one DocMDP certifying signature is allowed.
454        return $docMdpSignatureCount === 1;
455    }
456
457    /**
458     * ISO 32000-1 12.8.2.2.1: DocMDP shall be the first signed field
459     *
460     * "First signed field" means first CERTIFYING signature (has /Reference)
461     * that has been applied (/Contents present). Approval signatures (without
462     * /Reference) don't count as they cannot have DocMDP.
463     *
464     * @return bool True if DocMDP is in first certifying signature
465     */
466    private function validateDocMdpIsFirstSignature(string $content): bool {
467        $certifyingSignatures = $this->filterCertifyingSignatures($this->parsePdfObjects($content));
468
469        if (empty($certifyingSignatures)) {
470            return false;
471        }
472
473        usort($certifyingSignatures, fn ($a, $b) => $a['position'] <=> $b['position']);
474
475        return $this->signatureHasDocMdp($content, $certifyingSignatures[0]['dict']);
476    }
477
478    /**
479     * Filter only certifying signatures from parsed objects
480     *
481     * @param array $objects Parsed PDF objects
482     * @return array Certifying signatures with /Filter, /ByteRange, /Contents, /Reference
483     */
484    private function filterCertifyingSignatures(array $objects): array {
485        return array_filter($objects, function ($obj) {
486            $dict = $obj['dict'];
487            return preg_match('/\/Filter\s*\//', $dict)
488                && preg_match('/\/ByteRange\s*\[/', $dict)
489                && preg_match('/\/Contents\s*</', $dict)
490                && preg_match('/\/Reference\s*\[/', $dict);
491        });
492    }
493
494    /**
495     * Check if signature dictionary has DocMDP (inline or indirect)
496     *
497     * @param string $content Full PDF content
498     * @param string $dict Signature dictionary
499     * @return bool True if has DocMDP
500     */
501    private function signatureHasDocMdp(string $content, string $dict): bool {
502        if (preg_match('/\/Reference\s*\[.*?\/TransformMethod\s*\/DocMDP/s', $dict)) {
503            return true;
504        }
505
506        if (preg_match('/\/Reference\s*\[\s*(\d+)\s+\d+\s+R/', $dict, $refMatch)) {
507            $refPattern = '/' . $refMatch[1] . '\s+\d+\s+obj\s*<<.*?\/TransformMethod\s*\/DocMDP.*?>>.*?endobj/s';
508            return (bool)preg_match($refPattern, $content);
509        }
510
511        return false;
512    }
513
514    /**
515     * ISO 32000-1 Table 252: Validate signature dictionary entries
516     *
517     * Required entries:
518     * - /Type /Sig (optional, but if present must be /Sig)
519     * - /Filter (Required) - signature handler name
520     * - /ByteRange (Required for DocMDP) - byte ranges covered by signature
521     *
522     * @return bool True if signature dictionary is valid
523     */
524    private function validateSignatureDictionary(string $content): bool {
525        $objects = $this->parsePdfObjects($content);
526        $sigDict = $this->findSignatureDictionary($objects);
527
528        if (!$sigDict) {
529            return false;
530        }
531
532        return $this->validateDictionaryEntries($sigDict);
533    }
534
535    private function findSignatureDictionary(array $objects): ?string {
536        foreach ($objects as $obj) {
537            $dict = $obj['dict'];
538            if (preg_match('/\/Type\s*\/Sig\b/', (string)$dict) && preg_match('/\/Reference\s*\[/', (string)$dict)) {
539                return $dict;
540            }
541        }
542        return null;
543    }
544
545    /**
546     * Validate signature dictionary entries per ISO Table 252
547     *
548     * @param string $dict Dictionary content
549     * @return bool True if all required entries are valid
550     */
551    private function validateDictionaryEntries(string $dict): bool {
552        if (!preg_match('/\/Type\s*\/Sig\b/', $dict)) {
553            return false;
554        }
555
556        if (!preg_match('/\/Filter\s*\/[\w.]+/', $dict)) {
557            return false;
558        }
559
560        return (bool)preg_match('/\/ByteRange\s*\[/', $dict);
561    }
562
563    /**
564     * ISO 32000-1 Table 253: Validate signature reference dictionary
565     *
566     * @return bool True if /TransformMethod /DocMDP is present (inline or indirect)
567     */
568    private function validateSignatureReference(string $content): bool {
569        return (bool)preg_match('/\/TransformMethod\s*\/DocMDP/', $content);
570    }
571}