Code Coverage
 
Lines
Functions and Methods
Classes and Traits
Total
88.48% covered (warning)
88.48%
192 / 217
15.38% covered (danger)
15.38%
2 / 13
CRAP
0.00% covered (danger)
0.00%
0 / 1
PdfDecryptor
88.48% covered (warning)
88.48%
192 / 217
15.38% covered (danger)
15.38%
2 / 13
101.11
0.00% covered (danger)
0.00%
0 / 1
 __construct
100.00% covered (success)
100.00%
4 / 4
100.00% covered (success)
100.00%
1 / 1
1
 fromEncryptDict
98.39% covered (success)
98.39%
61 / 62
0.00% covered (danger)
0.00%
0 / 1
17
 decryptObject
44.44% covered (danger)
44.44%
4 / 9
0.00% covered (danger)
0.00%
0 / 1
9.29
 decryptDictionary
92.31% covered (success)
92.31%
12 / 13
0.00% covered (danger)
0.00%
0 / 1
8.03
 decryptStream
85.71% covered (warning)
85.71%
6 / 7
0.00% covered (danger)
0.00%
0 / 1
3.03
 decryptString
100.00% covered (success)
100.00%
5 / 5
100.00% covered (success)
100.00%
1 / 1
2
 decryptArray
80.00% covered (warning)
80.00%
8 / 10
0.00% covered (danger)
0.00%
0 / 1
5.20
 fromEncryptDictPublicKey
85.48% covered (warning)
85.48%
53 / 62
0.00% covered (danger)
0.00%
0 / 1
29.23
 fromEncryptDictR6
93.75% covered (success)
93.75%
15 / 16
0.00% covered (danger)
0.00%
0 / 1
9.02
 deriveObjectKey
87.50% covered (warning)
87.50%
7 / 8
0.00% covered (danger)
0.00%
0 / 1
2.01
 decrypt
81.82% covered (warning)
81.82%
9 / 11
0.00% covered (danger)
0.00%
0 / 1
5.15
 intVal
83.33% covered (warning)
83.33%
5 / 6
0.00% covered (danger)
0.00%
0 / 1
3.04
 stringVal
75.00% covered (warning)
75.00%
3 / 4
0.00% covered (danger)
0.00%
0 / 1
2.06
1<?php
2
3declare(strict_types=1);
4
5namespace Phpdftk\Pdf\Reader;
6
7use Phpdftk\Crypt\AesCipher;
8use Phpdftk\Crypt\PdfKeyDerivation;
9use Phpdftk\Crypt\PublicKeyEncryption;
10use Phpdftk\Crypt\Rc4Cipher;
11use Phpdftk\Pdf\Core\PdfArray;
12use Phpdftk\Pdf\Core\PdfDictionary;
13use Phpdftk\Pdf\Core\PdfName;
14use Phpdftk\Pdf\Core\PdfNumber;
15use Phpdftk\Pdf\Core\PdfString;
16use Phpdftk\Pdf\Core\Serializable;
17use Phpdftk\Pdf\Reader\Exception\InvalidPdfException;
18
19/**
20 * Decrypts PDF objects using the Standard security handler.
21 *
22 * Supports:
23 *   - V=1 R=2: RC4 40-bit
24 *   - V=2 R=3: RC4 variable length (40-128 bit)
25 *   - V=4 R=4: AES-128 or RC4-128 via crypt filters
26 *   - V=5 R=6: AES-256 via crypt filters
27 */
28final class PdfDecryptor
29{
30    private readonly string $encryptionKey;
31    private readonly bool $useAes;
32    private readonly int $revision;
33    private readonly int $aesKeyBits;
34
35    private function __construct(
36        string $encryptionKey,
37        bool $useAes,
38        int $revision,
39        int $aesKeyBits = 128,
40    ) {
41        $this->encryptionKey = $encryptionKey;
42        $this->useAes = $useAes;
43        $this->revision = $revision;
44        $this->aesKeyBits = $aesKeyBits;
45    }
46
47    /**
48     * Build a decryptor from the trailer's /Encrypt dictionary and a password.
49     *
50     * Tries the password as the user password first, then as the owner password.
51     *
52     * @throws InvalidPdfException If the password is wrong or the encryption is unsupported
53     */
54    public static function fromEncryptDict(
55        PdfDictionary $encryptDict,
56        string $password,
57        string $fileId,
58    ): self {
59        $v = self::intVal($encryptDict, 'V', 0);
60        $r = self::intVal($encryptDict, 'R', 2);
61
62        // V=5 R=6: AES-256
63        if ($v === 5) {
64            return self::fromEncryptDictR6($encryptDict, $password, $r);
65        }
66
67        $keyLengthBits = self::intVal($encryptDict, 'Length', $v === 1 ? 40 : 128);
68        $p = self::intVal($encryptDict, 'P', 0);
69
70        // Handle signed 32-bit P value
71        if ($p > 0x7FFFFFFF) {
72            $p = $p - 0x100000000;
73        }
74
75        $oValue = self::stringVal($encryptDict, 'O');
76        $uValue = self::stringVal($encryptDict, 'U');
77
78        if ($oValue === null || $uValue === null) {
79            throw new InvalidPdfException('Encrypt dictionary missing /O or /U values');
80        }
81
82        $encryptMetadata = true;
83        $emVal = $encryptDict->get('EncryptMetadata');
84        if ($emVal instanceof \Phpdftk\Pdf\Core\PdfBoolean) {
85            $encryptMetadata = $emVal->toPdf() === 'true';
86        }
87
88        // Determine cipher from crypt filters (V=4)
89        $useAes = false;
90        if ($v === 4) {
91            $stmF = $encryptDict->get('StmF');
92            $cfName = $stmF instanceof PdfName ? $stmF->value : 'StdCF';
93            $cf = $encryptDict->get('CF');
94            if ($cf instanceof PdfDictionary) {
95                $filter = $cf->get($cfName);
96                if ($filter instanceof PdfDictionary) {
97                    $cfm = $filter->get('CFM');
98                    if ($cfm instanceof PdfName && $cfm->value === 'AESV2') {
99                        $useAes = true;
100                    }
101                }
102            }
103        }
104
105        // Try user password
106        $key = PdfKeyDerivation::authenticateUserPassword(
107            $password,
108            $oValue,
109            $uValue,
110            $p,
111            $fileId,
112            $keyLengthBits,
113            $r,
114            $encryptMetadata,
115        );
116
117        // Try owner password
118        if ($key === null) {
119            $key = PdfKeyDerivation::authenticateOwnerPassword(
120                $password,
121                $oValue,
122                $uValue,
123                $p,
124                $fileId,
125                $keyLengthBits,
126                $r,
127                $encryptMetadata,
128            );
129        }
130
131        // Try empty password as fallback
132        if ($key === null && $password !== '') {
133            $key = PdfKeyDerivation::authenticateUserPassword(
134                '',
135                $oValue,
136                $uValue,
137                $p,
138                $fileId,
139                $keyLengthBits,
140                $r,
141                $encryptMetadata,
142            );
143        }
144
145        if ($key === null) {
146            throw new InvalidPdfException('Invalid password for encrypted PDF');
147        }
148
149        return new self($key, $useAes, $r);
150    }
151
152    /**
153     * Decrypt all string and stream values within a parsed object.
154     *
155     * The /Encrypt dictionary itself must NOT be decrypted.
156     */
157    public function decryptObject(Serializable $object, int $objNum, int $genNum): Serializable
158    {
159        if ($object instanceof PdfDictionary) {
160            return $this->decryptDictionary($object, $objNum, $genNum);
161        }
162        if ($object instanceof \Phpdftk\Pdf\Core\PdfStream) {
163            return $this->decryptStream($object, $objNum, $genNum);
164        }
165        if ($object instanceof PdfString) {
166            return $this->decryptString($object, $objNum, $genNum);
167        }
168        if ($object instanceof PdfArray) {
169            return $this->decryptArray($object, $objNum, $genNum);
170        }
171        return $object;
172    }
173
174    private function decryptDictionary(PdfDictionary $dict, int $objNum, int $genNum): PdfDictionary
175    {
176        // Don't decrypt the /Encrypt dictionary or XRef streams
177        $type = $dict->get('Type');
178        if ($type instanceof PdfName && ($type->value === 'Encrypt' || $type->value === 'XRef')) {
179            return $dict;
180        }
181
182        $result = new PdfDictionary();
183        foreach ($dict->entries as $key => $value) {
184            if ($value instanceof PdfString) {
185                $result->set($key, $this->decryptString($value, $objNum, $genNum));
186            } elseif ($value instanceof PdfArray) {
187                $result->set($key, $this->decryptArray($value, $objNum, $genNum));
188            } elseif ($value instanceof PdfDictionary) {
189                $result->set($key, $this->decryptDictionary($value, $objNum, $genNum));
190            } else {
191                $result->set($key, $value);
192            }
193        }
194        return $result;
195    }
196
197    private function decryptStream(\Phpdftk\Pdf\Core\PdfStream $stream, int $objNum, int $genNum): \Phpdftk\Pdf\Core\PdfStream
198    {
199        // Don't decrypt XRef streams or metadata streams (when EncryptMetadata=false)
200        $type = $stream->dictionary->get('Type');
201        if ($type instanceof PdfName && $type->value === 'XRef') {
202            return $stream;
203        }
204
205        // Decrypt the stream data
206        $objectKey = $this->deriveObjectKey($objNum, $genNum);
207        $decryptedData = $this->decrypt($stream->data, $objectKey);
208
209        // Decrypt strings in the dictionary
210        $decryptedDict = $this->decryptDictionary($stream->dictionary, $objNum, $genNum);
211
212        return new \Phpdftk\Pdf\Core\PdfStream($decryptedDict, $decryptedData);
213    }
214
215    private function decryptString(PdfString $string, int $objNum, int $genNum): PdfString
216    {
217        if ($string->value === '') {
218            return $string;
219        }
220
221        $objectKey = $this->deriveObjectKey($objNum, $genNum);
222        $decrypted = $this->decrypt($string->value, $objectKey);
223
224        return new PdfString($decrypted, $string->hex);
225    }
226
227    private function decryptArray(PdfArray $array, int $objNum, int $genNum): PdfArray
228    {
229        $items = [];
230        foreach ($array->items as $item) {
231            if ($item instanceof PdfString) {
232                $items[] = $this->decryptString($item, $objNum, $genNum);
233            } elseif ($item instanceof PdfDictionary) {
234                $items[] = $this->decryptDictionary($item, $objNum, $genNum);
235            } elseif ($item instanceof PdfArray) {
236                $items[] = $this->decryptArray($item, $objNum, $genNum);
237            } else {
238                $items[] = $item;
239            }
240        }
241        return new PdfArray($items);
242    }
243
244    /**
245     * Build a decryptor from a public-key (Adobe.PubSec) encrypt dictionary.
246     *
247     * Extracts the PKCS#7 envelopes from the crypt filter's /Recipients,
248     * decrypts the seed using the provided certificate and private key,
249     * then derives the file encryption key.
250     *
251     * @throws InvalidPdfException If no matching recipient found or encryption unsupported
252     */
253    public static function fromEncryptDictPublicKey(
254        PdfDictionary $encryptDict,
255        string $certPem,
256        string $privateKeyPem,
257        string $fileId,
258    ): self {
259        $v = self::intVal($encryptDict, 'V', 0);
260
261        // Determine cipher and locate Recipients
262        $useAes = false;
263        $recipientStrings = [];
264
265        if ($v === 4) {
266            // V=4: Recipients in crypt filter
267            $stmF = $encryptDict->get('StmF');
268            $cfName = $stmF instanceof PdfName ? $stmF->value : 'DefaultCryptFilter';
269            $cf = $encryptDict->get('CF');
270            if ($cf instanceof PdfDictionary) {
271                $filter = $cf->get($cfName);
272                if ($filter instanceof PdfDictionary) {
273                    $cfm = $filter->get('CFM');
274                    if ($cfm instanceof PdfName && ($cfm->value === 'AESV2' || $cfm->value === 'AESV3')) {
275                        $useAes = true;
276                    }
277                    $recipients = $filter->get('Recipients');
278                    if ($recipients instanceof PdfArray) {
279                        foreach ($recipients->items as $item) {
280                            if ($item instanceof PdfString) {
281                                $recipientStrings[] = $item->value;
282                            }
283                        }
284                    } elseif ($recipients instanceof PdfString) {
285                        $recipientStrings[] = $recipients->value;
286                    }
287                }
288            }
289        } else {
290            // V=1/2/3: Recipients in encrypt dictionary (adbe.pkcs7.s3)
291            $recipients = $encryptDict->get('Recipients');
292            if ($recipients instanceof PdfArray) {
293                foreach ($recipients->items as $item) {
294                    if ($item instanceof PdfString) {
295                        $recipientStrings[] = $item->value;
296                    }
297                }
298            }
299        }
300
301        if ($recipientStrings === []) {
302            throw new InvalidPdfException('No /Recipients found in public-key encrypt dictionary');
303        }
304
305        // Try to decrypt each recipient envelope to find our seed
306        $seed = null;
307        foreach ($recipientStrings as $der) {
308            $seed = PublicKeyEncryption::openEnvelope($der, $certPem, $privateKeyPem);
309            if ($seed !== null) {
310                break;
311            }
312        }
313
314        if ($seed === null) {
315            throw new InvalidPdfException('No matching recipient for the provided certificate');
316        }
317
318        $encryptMetadata = true;
319        $emVal = $encryptDict->get('EncryptMetadata');
320        if ($emVal instanceof \Phpdftk\Pdf\Core\PdfBoolean) {
321            $encryptMetadata = $emVal->toPdf() === 'true';
322        }
323
324        // Compute combined permissions from all recipients (we don't know
325        // individual permissions, so use the /P value if available, or -1)
326        $p = self::intVal($encryptDict, 'P', -1);
327        if ($p > 0x7FFFFFFF) {
328            $p = $p - 0x100000000;
329        }
330
331        // Key length — detect AESV3 (AES-256) vs AESV2 (AES-128)
332        $aesKeyBits = 128;
333        if ($v === 4) {
334            $stmF = $encryptDict->get('StmF');
335            $cfName = $stmF instanceof PdfName ? $stmF->value : 'DefaultCryptFilter';
336            $cf = $encryptDict->get('CF');
337            if ($cf instanceof PdfDictionary) {
338                $filter = $cf->get($cfName);
339                if ($filter instanceof PdfDictionary) {
340                    $cfm = $filter->get('CFM');
341                    if ($cfm instanceof PdfName && $cfm->value === 'AESV3') {
342                        $aesKeyBits = 256;
343                    }
344                }
345            }
346        }
347        $keyLengthBytes = $aesKeyBits / 8;
348
349        // Derive file encryption key
350        $encryptionKey = PublicKeyEncryption::deriveFileKey(
351            $seed,
352            $recipientStrings,
353            $p,
354            (int) $keyLengthBytes,
355            $encryptMetadata,
356        );
357
358        $revision = self::intVal($encryptDict, 'R', 4);
359
360        return new self($encryptionKey, $useAes, $revision, $aesKeyBits);
361    }
362
363    /**
364     * Build a decryptor for V=5 R=6 (AES-256).
365     */
366    private static function fromEncryptDictR6(
367        PdfDictionary $encryptDict,
368        string $password,
369        int $r,
370    ): self {
371        $uValue = self::stringVal($encryptDict, 'U');
372        $ueValue = self::stringVal($encryptDict, 'UE');
373        $oValue = self::stringVal($encryptDict, 'O');
374        $oeValue = self::stringVal($encryptDict, 'OE');
375
376        if ($uValue === null || $ueValue === null || $oValue === null || $oeValue === null) {
377            throw new InvalidPdfException('Encrypt dictionary missing /U, /UE, /O, or /OE values for R=6');
378        }
379
380        // SASLprep + truncate to 127 bytes
381        $pw = PdfKeyDerivation::preparePasswordR6($password);
382
383        // Try user password
384        $key = PdfKeyDerivation::authenticateUserPasswordR6($pw, $uValue, $ueValue);
385
386        // Try owner password
387        if ($key === null) {
388            $key = PdfKeyDerivation::authenticateOwnerPasswordR6($pw, $oValue, $oeValue, $uValue);
389        }
390
391        // Try empty password as fallback
392        if ($key === null && $password !== '') {
393            $emptyPw = PdfKeyDerivation::preparePasswordR6('');
394            $key = PdfKeyDerivation::authenticateUserPasswordR6($emptyPw, $uValue, $ueValue);
395        }
396
397        if ($key === null) {
398            throw new InvalidPdfException('Invalid password for encrypted PDF');
399        }
400
401        return new self($key, true, $r, 256);
402    }
403
404    private function deriveObjectKey(int $objNum, int $genNum): string
405    {
406        // V=5 R=6: use file encryption key directly (no per-object derivation)
407        if ($this->aesKeyBits === 256) {
408            return $this->encryptionKey;
409        }
410
411        return PdfKeyDerivation::deriveObjectKey(
412            $this->encryptionKey,
413            $objNum,
414            $genNum,
415            $this->useAes,
416        );
417    }
418
419    private function decrypt(string $data, string $key): string
420    {
421        if ($data === '') {
422            return '';
423        }
424
425        if ($this->useAes) {
426            if (strlen($data) < 16) {
427                return $data; // Too short for AES (no IV)
428            }
429            $aes = new AesCipher($this->aesKeyBits);
430            try {
431                return $aes->decrypt($data, $key);
432            } catch (\RuntimeException) {
433                return $data; // Decryption failed — return raw
434            }
435        }
436
437        $rc4 = new Rc4Cipher();
438        return $rc4->decrypt($data, $key);
439    }
440
441    private static function intVal(PdfDictionary $dict, string $key, int $default): int
442    {
443        $val = $dict->get($key);
444        if ($val instanceof PdfNumber) {
445            return (int) $val->toPdf();
446        }
447        if (is_int($val)) {
448            return $val;
449        }
450        return $default;
451    }
452
453    private static function stringVal(PdfDictionary $dict, string $key): ?string
454    {
455        $val = $dict->get($key);
456        if ($val instanceof PdfString) {
457            return $val->value;
458        }
459        return null;
460    }
461}