Code Coverage
 
Lines
Functions and Methods
Classes and Traits
Total
81.76% covered (warning)
81.76%
121 / 148
80.00% covered (warning)
80.00%
16 / 20
CRAP
0.00% covered (danger)
0.00%
0 / 1
LtvSigner
81.76% covered (warning)
81.76%
121 / 148
80.00% covered (warning)
80.00%
16 / 20
78.43
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
 open
100.00% covered (success)
100.00%
2 / 2
100.00% covered (success)
100.00%
1 / 1
1
 openString
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 setOcspClient
100.00% covered (success)
100.00%
2 / 2
100.00% covered (success)
100.00%
1 / 1
1
 setCrlClient
100.00% covered (success)
100.00%
2 / 2
100.00% covered (success)
100.00%
1 / 1
1
 addOcspResponse
100.00% covered (success)
100.00%
2 / 2
100.00% covered (success)
100.00%
1 / 1
1
 addCrl
100.00% covered (success)
100.00%
2 / 2
100.00% covered (success)
100.00%
1 / 1
1
 addCertificate
100.00% covered (success)
100.00%
2 / 2
100.00% covered (success)
100.00%
1 / 1
1
 forSignature
100.00% covered (success)
100.00%
2 / 2
100.00% covered (success)
100.00%
1 / 1
1
 save
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 toBytes
100.00% covered (success)
100.00%
29 / 29
100.00% covered (success)
100.00%
1 / 1
9
 getVersionWarnings
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 getWarnings
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 getReader
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 getPageCount
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 discoverSignatures
84.21% covered (warning)
84.21%
16 / 19
0.00% covered (danger)
0.00%
0 / 1
6.14
 walkFieldForSignatures
84.00% covered (warning)
84.00%
21 / 25
0.00% covered (danger)
0.00%
0 / 1
13.69
 processSignature
45.45% covered (danger)
45.45%
15 / 33
0.00% covered (danger)
0.00%
0 / 1
26.23
 updateCatalog
88.89% covered (warning)
88.89%
16 / 18
0.00% covered (danger)
0.00%
0 / 1
4.02
 resolve
100.00% covered (success)
100.00%
3 / 3
100.00% covered (success)
100.00%
1 / 1
2
1<?php
2
3declare(strict_types=1);
4
5namespace Phpdftk\Pdf\Toolkit;
6
7use Phpdftk\Pdf\Core\Document\DssBuilder;
8use Phpdftk\Pdf\Core\File\IncrementalWriter;
9use Phpdftk\Filesystem\LocalFilesystem;
10use Phpdftk\Pdf\Core\Interactive\Signature\CertificateUtils;
11use Phpdftk\Pdf\Core\Interactive\Signature\CrlClient;
12use Phpdftk\Pdf\Core\Interactive\Signature\OcspClient;
13use Phpdftk\Pdf\Core\PdfArray;
14use Phpdftk\Pdf\Core\PdfDictionary;
15use Phpdftk\Pdf\Core\PdfName;
16use Phpdftk\Pdf\Core\PdfObject;
17use Phpdftk\Pdf\Core\PdfReference;
18use Phpdftk\Pdf\Core\PdfString;
19use Phpdftk\Pdf\Reader\PdfReader;
20
21/**
22 * Add LTV (Long-Term Validation) data to signed PDFs — PAdES B-LT profile.
23 *
24 * Extracts certificates from existing signatures, fetches OCSP responses
25 * and CRLs, and embeds them in a DSS (Document Security Store) via an
26 * incremental update that preserves the original signatures.
27 *
28 * Supports both online fetching (via OcspClient/CrlClient) and offline
29 * mode (pre-loaded OCSP/CRL data for testing).
30 *
31 * Usage:
32 *   LtvSigner::openString($signedPdfBytes)
33 *       ->setOcspClient(new OcspClient())
34 *       ->setCrlClient(new CrlClient())
35 *       ->save('ltv-enabled.pdf');
36 *
37 *   // Offline / testing:
38 *   LtvSigner::openString($signedPdfBytes)
39 *       ->addOcspResponse($derOcspBytes)
40 *       ->addCertificate($derCaCert)
41 *       ->save('ltv-enabled.pdf');
42 *
43 * @api
44 */
45final class LtvSigner
46{
47    private string $originalBytes;
48
49    private ?OcspClient $ocspClient = null;
50    private ?CrlClient $crlClient = null;
51
52    /** @var list<string> Pre-loaded DER OCSP responses */
53    private array $preloadedOcsps = [];
54
55    /** @var list<string> Pre-loaded DER CRLs */
56    private array $preloadedCrls = [];
57
58    /** @var list<string> Extra DER certificates to include */
59    private array $extraCerts = [];
60
61    /** @var list<string> Target specific signature field names (empty = all) */
62    private array $targetSignatures = [];
63
64    /** @var list<string> */
65    private array $lastVersionWarnings = [];
66
67    /** @var list<string> Non-fatal warnings (OCSP/CRL fetch failures) */
68    private array $warnings = [];
69
70    private function __construct(
71        private readonly PdfReader $reader,
72        string $originalBytes,
73    ) {
74        $this->originalBytes = $originalBytes;
75    }
76
77    public static function open(string $path, string $password = ''): self
78    {
79        $bytes = LocalFilesystem::readFile($path);
80        return new self(PdfReader::fromString($bytes, $password), $bytes);
81    }
82
83    public static function openString(string $pdfBytes, string $password = ''): self
84    {
85        return new self(PdfReader::fromString($pdfBytes, $password), $pdfBytes);
86    }
87
88    // -----------------------------------------------------------------------
89    // Configuration (fluent)
90    // -----------------------------------------------------------------------
91
92    /**
93     * Set the OCSP client for online revocation checking.
94     */
95    public function setOcspClient(OcspClient $client): self
96    {
97        $this->ocspClient = $client;
98        return $this;
99    }
100
101    /**
102     * Set the CRL client for online revocation checking.
103     */
104    public function setCrlClient(CrlClient $client): self
105    {
106        $this->crlClient = $client;
107        return $this;
108    }
109
110    /**
111     * Pre-load a DER-encoded OCSP response (for offline/testing use).
112     */
113    public function addOcspResponse(string $derOcsp): self
114    {
115        $this->preloadedOcsps[] = $derOcsp;
116        return $this;
117    }
118
119    /**
120     * Pre-load a DER-encoded CRL (for offline/testing use).
121     */
122    public function addCrl(string $derCrl): self
123    {
124        $this->preloadedCrls[] = $derCrl;
125        return $this;
126    }
127
128    /**
129     * Add an extra DER-encoded certificate to include in the DSS.
130     */
131    public function addCertificate(string $derCert): self
132    {
133        $this->extraCerts[] = $derCert;
134        return $this;
135    }
136
137    /**
138     * Target a specific signature field by name. Can be called multiple times.
139     * If never called, all signatures are processed.
140     *
141     * @throws \InvalidArgumentException if the field does not exist
142     */
143    public function forSignature(string $fieldName): self
144    {
145        $this->targetSignatures[] = $fieldName;
146        return $this;
147    }
148
149    // -----------------------------------------------------------------------
150    // Output
151    // -----------------------------------------------------------------------
152
153    public function save(string $path): void
154    {
155        LocalFilesystem::writeFile($path, $this->toBytes(), createDirectories: true);
156    }
157
158    public function toBytes(): string
159    {
160        $this->warnings = [];
161
162        // Discover signatures in the PDF
163        $signatures = $this->discoverSignatures();
164
165        if (empty($signatures)) {
166            return $this->originalBytes;
167        }
168
169        // Filter by target if specified
170        if (!empty($this->targetSignatures)) {
171            $filtered = [];
172            foreach ($this->targetSignatures as $name) {
173                if (!isset($signatures[$name])) {
174                    throw new \InvalidArgumentException("Signature field not found: $name");
175                }
176                $filtered[$name] = $signatures[$name];
177            }
178            $signatures = $filtered;
179        }
180
181        $writer = IncrementalWriter::fromReader($this->reader, $this->originalBytes);
182        $builder = new DssBuilder($writer);
183
184        // Add extra certificates
185        foreach ($this->extraCerts as $cert) {
186            $builder->addCertificate($cert);
187        }
188
189        // Add pre-loaded OCSP responses to DSS global pool
190        $preloadedOcspRefs = [];
191        foreach ($this->preloadedOcsps as $ocsp) {
192            $preloadedOcspRefs[] = $builder->addOcspResponse($ocsp);
193        }
194
195        // Add pre-loaded CRLs to DSS global pool
196        $preloadedCrlRefs = [];
197        foreach ($this->preloadedCrls as $crl) {
198            $preloadedCrlRefs[] = $builder->addCrl($crl);
199        }
200
201        // Process each signature
202        foreach ($signatures as $name => $sigData) {
203            $this->processSignature($name, $sigData, $builder, $preloadedOcspRefs, $preloadedCrlRefs);
204        }
205
206        // Build DSS and register with writer
207        $dss = $builder->build();
208        $dssRef = $writer->addNewObject($dss);
209
210        // Update Catalog to include /DSS
211        $this->updateCatalog($writer, $dssRef);
212
213        $result = $writer->generate();
214        $this->lastVersionWarnings = $writer->getVersionWarnings();
215        return $result;
216    }
217
218    // -----------------------------------------------------------------------
219    // Escape hatches
220    // -----------------------------------------------------------------------
221
222    /** @return list<string> */
223    public function getVersionWarnings(): array
224    {
225        return $this->lastVersionWarnings;
226    }
227
228    /**
229     * Get non-fatal warnings from OCSP/CRL fetching.
230     *
231     * @return list<string>
232     */
233    public function getWarnings(): array
234    {
235        return $this->warnings;
236    }
237
238    public function getReader(): PdfReader
239    {
240        return $this->reader;
241    }
242
243    public function getPageCount(): int
244    {
245        return $this->reader->getPageCount();
246    }
247
248    // -----------------------------------------------------------------------
249    // Internal: signature discovery
250    // -----------------------------------------------------------------------
251
252    /**
253     * Discover all signature fields in the PDF.
254     *
255     * @return array<string, array{contentsRaw: string}>
256     *         Field name => signature data
257     */
258    private function discoverSignatures(): array
259    {
260        $trailer = $this->reader->getTrailer();
261        $rootRef = $trailer->get('Root');
262        if (!$rootRef instanceof PdfReference) {
263            return [];
264        }
265
266        $catalog = $this->reader->resolveReference($rootRef);
267        if (!$catalog instanceof PdfDictionary) {
268            return [];
269        }
270
271        $acroFormVal = $catalog->get('AcroForm');
272        $acroForm = $this->resolve($acroFormVal);
273        if (!$acroForm instanceof PdfDictionary) {
274            return [];
275        }
276
277        $fieldsVal = $acroForm->get('Fields');
278        $fieldsArray = $this->resolve($fieldsVal);
279        if (!$fieldsArray instanceof PdfArray) {
280            return [];
281        }
282
283        $signatures = [];
284        foreach ($fieldsArray->items as $fieldRef) {
285            $this->walkFieldForSignatures($fieldRef, '', $signatures);
286        }
287
288        return $signatures;
289    }
290
291    /**
292     * Recursively walk fields looking for signature fields with values.
293     *
294     * @param array<string, array{contentsHex: string, contentsRaw: string}> $signatures
295     */
296    private function walkFieldForSignatures(
297        mixed $fieldRefOrDict,
298        string $parentName,
299        array &$signatures,
300    ): void {
301        $fieldDict = $this->resolve($fieldRefOrDict);
302        if (!$fieldDict instanceof PdfDictionary) {
303            return;
304        }
305
306        // Build fully-qualified name
307        $partialName = '';
308        $tVal = $fieldDict->get('T');
309        if ($tVal instanceof PdfString) {
310            $partialName = $tVal->value;
311        }
312
313        $fullName = $parentName !== '' && $partialName !== ''
314            ? $parentName . '.' . $partialName
315            : ($partialName !== '' ? $partialName : $parentName);
316
317        // Check if this is a signature field with a value
318        $ft = $fieldDict->get('FT');
319        if ($ft instanceof PdfName && $ft->value === 'Sig') {
320            $vRef = $fieldDict->get('V');
321            $sigValue = $this->resolve($vRef);
322            if ($sigValue instanceof PdfDictionary) {
323                $contents = $sigValue->get('Contents');
324                if ($contents instanceof PdfString && $contents->value !== '') {
325                    // PdfString with hex=true: value is already raw binary bytes
326                    // PdfString with hex=false: value is the literal string
327                    $signatures[$fullName] = [
328                        'contentsRaw' => $contents->value,
329                    ];
330                }
331            }
332        }
333
334        // Recurse into /Kids
335        $kidsVal = $fieldDict->get('Kids');
336        $kids = $this->resolve($kidsVal);
337        if ($kids instanceof PdfArray) {
338            foreach ($kids->items as $kidRef) {
339                $this->walkFieldForSignatures($kidRef, $fullName, $signatures);
340            }
341        }
342    }
343
344    // -----------------------------------------------------------------------
345    // Internal: signature processing
346    // -----------------------------------------------------------------------
347
348    /**
349     * Process a single signature: extract certs, fetch revocation data, add to DSS.
350     *
351     * @param array{contentsRaw: string} $sigData
352     * @param list<PdfReference> $preloadedOcspRefs
353     * @param list<PdfReference> $preloadedCrlRefs
354     */
355    private function processSignature(
356        string $name,
357        array $sigData,
358        DssBuilder $builder,
359        array $preloadedOcspRefs,
360        array $preloadedCrlRefs,
361    ): void {
362        $rawDer = $sigData['contentsRaw'];
363
364        // Strip trailing zero padding from the signature placeholder
365        $rawDer = rtrim($rawDer, "\x00");
366
367        if ($rawDer === '') {
368            $this->warnings[] = "Signature '$name': empty /Contents value, skipping";
369            return;
370        }
371
372        // Extract certificates from PKCS#7
373        $certsDer = [];
374        try {
375            $certsDer = CertificateUtils::extractCertsFromPkcs7Der($rawDer);
376        } catch (\RuntimeException $e) {
377            $this->warnings[] = "Signature '$name': failed to extract certificates: {$e->getMessage()}";
378            return;
379        }
380
381        // Order as chain (leaf → root)
382        $certsDer = CertificateUtils::buildChain($certsDer);
383
384        // Add all certificates to DSS
385        $certRefs = [];
386        foreach ($certsDer as $certDer) {
387            $certRefs[] = $builder->addCertificate($certDer);
388        }
389
390        // Fetch OCSP responses for each cert (except root/self-signed)
391        $ocspRefs = $preloadedOcspRefs;
392        for ($i = 0; $i < count($certsDer) - 1; $i++) {
393            $cert = $certsDer[$i];
394            $issuer = $certsDer[$i + 1] ?? $certsDer[$i];
395
396            if ($this->ocspClient !== null) {
397                try {
398                    $ocspDer = $this->ocspClient->getOcspResponse($cert, $issuer);
399                    $ocspRefs[] = $builder->addOcspResponse($ocspDer);
400                } catch (\RuntimeException $e) {
401                    $this->warnings[] = "Signature '$name': OCSP fetch failed for cert $i{$e->getMessage()}";
402                }
403            }
404        }
405
406        // Fetch CRLs for each cert (except root)
407        $crlRefs = $preloadedCrlRefs;
408        for ($i = 0; $i < count($certsDer) - 1; $i++) {
409            $cert = $certsDer[$i];
410
411            if ($this->crlClient !== null) {
412                try {
413                    $crlDer = $this->crlClient->getCrl($cert);
414                    $crlRefs[] = $builder->addCrl($crlDer);
415                } catch (\RuntimeException $e) {
416                    $this->warnings[] = "Signature '$name': CRL fetch failed for cert $i{$e->getMessage()}";
417                }
418            }
419        }
420
421        // Add VRI entry
422        $vriKey = DssBuilder::computeVriKey($rawDer);
423        $builder->addVriEntry($vriKey, $certRefs, $ocspRefs, $crlRefs);
424    }
425
426    // -----------------------------------------------------------------------
427    // Internal: catalog update
428    // -----------------------------------------------------------------------
429
430    /**
431     * Update the Catalog to include the /DSS reference via incremental update.
432     */
433    private function updateCatalog(IncrementalWriter $writer, PdfReference $dssRef): void
434    {
435        $trailer = $this->reader->getTrailer();
436        $rootRef = $trailer->get('Root');
437        if (!$rootRef instanceof PdfReference) {
438            throw new \RuntimeException('Cannot find /Root reference in trailer');
439        }
440
441        $catalogDict = $this->reader->resolveReference($rootRef);
442        if (!$catalogDict instanceof PdfDictionary) {
443            throw new \RuntimeException('Cannot resolve Catalog dictionary');
444        }
445
446        // Clone the catalog dictionary and add /DSS
447        $modifiedDict = new PdfDictionary($catalogDict->entries);
448        $modifiedDict->set('DSS', $dssRef);
449
450        // If version needs bumping to 2.0 for DSS, set /Version
451        if ($writer->wasVersionBumped()) {
452            $modifiedDict->set('Version', new PdfName($writer->getPdfVersion()->value));
453        }
454
455        $wrapper = new class ($modifiedDict) extends PdfObject {
456            public function __construct(private readonly PdfDictionary $dict) {}
457            public function toPdf(): string
458            {
459                return $this->dict->toPdf();
460            }
461        };
462        $wrapper->objectNumber = $rootRef->objectNumber;
463        $wrapper->generationNumber = 0;
464
465        $writer->addModifiedObject($wrapper);
466    }
467
468    /**
469     * Resolve a value that might be a PdfReference.
470     */
471    private function resolve(mixed $val): mixed
472    {
473        if ($val instanceof PdfReference) {
474            return $this->reader->resolveReference($val);
475        }
476        return $val;
477    }
478}