Code Coverage
 
Lines
Functions and Methods
Classes and Traits
Total
94.94% covered (success)
94.94%
225 / 237
71.43% covered (warning)
71.43%
15 / 21
CRAP
0.00% covered (danger)
0.00%
0 / 1
PdfEncryptor
94.94% covered (success)
94.94%
225 / 237
71.43% covered (warning)
71.43%
15 / 21
53.36
0.00% covered (danger)
0.00%
0 / 1
 __construct
100.00% covered (success)
100.00%
5 / 5
100.00% covered (success)
100.00%
1 / 1
1
 rc4128
100.00% covered (success)
100.00%
10 / 10
100.00% covered (success)
100.00%
1 / 1
1
 rc440
100.00% covered (success)
100.00%
10 / 10
100.00% covered (success)
100.00%
1 / 1
1
 aes128
100.00% covered (success)
100.00%
10 / 10
100.00% covered (success)
100.00%
1 / 1
1
 aes256
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 publicKeyAes128
97.30% covered (success)
97.30%
36 / 37
0.00% covered (danger)
0.00%
0 / 1
4
 publicKeyAes256
97.37% covered (success)
97.37%
37 / 38
0.00% covered (danger)
0.00%
0 / 1
4
 getEncryptDictionary
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 getMinimumPdfVersion
100.00% covered (success)
100.00%
3 / 3
100.00% covered (success)
100.00%
1 / 1
4
 setEncryptDictObjNum
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 getFileId
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 encryptObject
100.00% covered (success)
100.00%
7 / 7
100.00% covered (success)
100.00%
1 / 1
3
 encryptObjectProperties
88.89% covered (warning)
88.89%
8 / 9
0.00% covered (danger)
0.00%
0 / 1
5.03
 encryptStream
85.71% covered (warning)
85.71%
6 / 7
0.00% covered (danger)
0.00%
0 / 1
4.05
 encryptDictionary
57.14% covered (warning)
57.14%
4 / 7
0.00% covered (danger)
0.00%
0 / 1
6.97
 encryptString
100.00% covered (success)
100.00%
5 / 5
100.00% covered (success)
100.00%
1 / 1
2
 encryptArray
61.54% covered (warning)
61.54%
8 / 13
0.00% covered (danger)
0.00%
0 / 1
8.05
 deriveObjectKey
100.00% covered (success)
100.00%
8 / 8
100.00% covered (success)
100.00%
1 / 1
2
 encrypt
100.00% covered (success)
100.00%
5 / 5
100.00% covered (success)
100.00%
1 / 1
2
 createStandard
100.00% covered (success)
100.00%
28 / 28
100.00% covered (success)
100.00%
1 / 1
3
 createR6
100.00% covered (success)
100.00%
31 / 31
100.00% covered (success)
100.00%
1 / 1
1
1<?php
2
3declare(strict_types=1);
4
5namespace Phpdftk\Pdf\Core\Security;
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\PdfObject;
16use Phpdftk\Pdf\Core\PdfReference;
17use Phpdftk\Pdf\Core\PdfStream;
18use Phpdftk\Pdf\Core\PdfString;
19use Phpdftk\Pdf\Core\Serializable;
20
21/**
22 * Encrypts PDF objects for the Standard and Adobe.PubSec security handlers.
23 *
24 * Standard handler (password-based):
25 *   - V=1 R=2: RC4 40-bit  (PDF 1.1+)
26 *   - V=2 R=3: RC4 128-bit (PDF 1.4+)
27 *   - V=4 R=4: AES-128     (PDF 1.6+)
28 *   - V=5 R=6: AES-256     (PDF 2.0)
29 *
30 * Public-key handler (certificate-based, Adobe.PubSec):
31 *   - V=4 CFM=AESV2: AES-128 with PKCS#7 recipient envelopes
32 *   - V=4 CFM=AESV3: AES-256 with PKCS#7 recipient envelopes
33 */
34final class PdfEncryptor
35{
36    /** Print the document. */
37    public const PERM_PRINT = 4;
38    /** Modify document contents. */
39    public const PERM_MODIFY = 8;
40    /** Copy/extract text and graphics. */
41    public const PERM_COPY = 16;
42    /** Add or modify annotations. */
43    public const PERM_ANNOTATE = 32;
44    /** Fill in form fields. */
45    public const PERM_FILL_FORMS = 256;
46    /** Extract text for accessibility. */
47    public const PERM_ACCESSIBILITY = 512;
48    /** Assemble the document (insert, rotate, delete pages). */
49    public const PERM_ASSEMBLE = 1024;
50    /** High-quality print. */
51    public const PERM_PRINT_HIGH = 2048;
52    /** All permissions granted. */
53    public const PERM_ALL = self::PERM_PRINT | self::PERM_MODIFY | self::PERM_COPY
54        | self::PERM_ANNOTATE | self::PERM_FILL_FORMS | self::PERM_ACCESSIBILITY
55        | self::PERM_ASSEMBLE | self::PERM_PRINT_HIGH;
56
57    private readonly string $encryptionKey;
58    private readonly EncryptDictionary $encryptDict;
59    private readonly bool $useAes;
60    private readonly string $fileId;
61    private readonly int $aesKeyBits;
62
63    /** @var int Object number of the encrypt dictionary (must not be encrypted) */
64    private int $encryptDictObjNum = 0;
65
66    private function __construct(
67        string $encryptionKey,
68        EncryptDictionary $encryptDict,
69        bool $useAes,
70        string $fileId,
71        int $aesKeyBits = 128,
72    ) {
73        $this->encryptionKey = $encryptionKey;
74        $this->encryptDict = $encryptDict;
75        $this->useAes = $useAes;
76        $this->fileId = $fileId;
77        $this->aesKeyBits = $aesKeyBits;
78    }
79
80    /**
81     * Create an encryptor with RC4 128-bit encryption (V=2 R=3, PDF 1.4+).
82     *
83     * @param int $permissions Bitmask of PERM_* constants
84     */
85    public static function rc4128(
86        string $userPassword,
87        string $ownerPassword,
88        string $fileId,
89        int $permissions = self::PERM_ALL,
90    ): self {
91        return self::createStandard(
92            $userPassword,
93            $ownerPassword,
94            $fileId,
95            $permissions,
96            keyLengthBits: 128,
97            v: 2,
98            r: 3,
99            useAes: false,
100        );
101    }
102
103    /**
104     * Create an encryptor with RC4 40-bit encryption (V=1 R=2, PDF 1.1+).
105     *
106     * @param int $permissions Bitmask of PERM_* constants
107     */
108    public static function rc440(
109        string $userPassword,
110        string $ownerPassword,
111        string $fileId,
112        int $permissions = self::PERM_ALL,
113    ): self {
114        return self::createStandard(
115            $userPassword,
116            $ownerPassword,
117            $fileId,
118            $permissions,
119            keyLengthBits: 40,
120            v: 1,
121            r: 2,
122            useAes: false,
123        );
124    }
125
126    /**
127     * Create an encryptor with AES 128-bit encryption (V=4 R=4, PDF 1.6+).
128     *
129     * @param int $permissions Bitmask of PERM_* constants
130     */
131    public static function aes128(
132        string $userPassword,
133        string $ownerPassword,
134        string $fileId,
135        int $permissions = self::PERM_ALL,
136    ): self {
137        return self::createStandard(
138            $userPassword,
139            $ownerPassword,
140            $fileId,
141            $permissions,
142            keyLengthBits: 128,
143            v: 4,
144            r: 4,
145            useAes: true,
146        );
147    }
148
149    /**
150     * Create an encryptor with AES 256-bit encryption (V=5 R=6, PDF 2.0).
151     *
152     * @param int $permissions Bitmask of PERM_* constants
153     */
154    public static function aes256(
155        string $userPassword,
156        string $ownerPassword,
157        string $fileId,
158        int $permissions = self::PERM_ALL,
159    ): self {
160        return self::createR6($userPassword, $ownerPassword, $fileId, $permissions);
161    }
162
163    /**
164     * Create an encryptor with public-key (certificate-based) AES-128 encryption.
165     *
166     * Uses the Adobe.PubSec handler with /SubFilter /adbe.pkcs7.s5 (V=4).
167     * Each recipient's certificate receives a PKCS#7 envelope containing the
168     * encryption seed. Different recipients may have different permissions.
169     *
170     * @param array<array{cert: string, permissions?: int}> $recipients
171     *     Each entry: 'cert' = PEM certificate, 'permissions' = PERM_* bitmask (default PERM_ALL)
172     * @param string $fileId File identifier for the /ID trailer entry
173     */
174    public static function publicKeyAes128(
175        array $recipients,
176        string $fileId,
177    ): self {
178        if ($recipients === []) {
179            throw new \InvalidArgumentException('At least one recipient certificate is required');
180        }
181
182        // Generate 20-byte random seed
183        $seed = random_bytes(20);
184        $encryptMetadata = true;
185
186        // Build PKCS#7 envelopes and compute combined permissions
187        $recipientDerStrings = [];
188        $combinedPermissions = self::PERM_ALL | 0xFFFFF000 | 0xC0;
189
190        foreach ($recipients as $r) {
191            $certPem = $r['cert'];
192            $perms = ($r['permissions'] ?? self::PERM_ALL) | 0xFFFFF000 | 0xC0;
193            $combinedPermissions &= $perms;
194
195            $der = PublicKeyEncryption::createEnvelope($seed, $perms, $certPem, $encryptMetadata);
196            $recipientDerStrings[] = $der;
197        }
198
199        // Derive file encryption key: SHA-1(seed || recipients || P || metadata_flag)
200        $encryptionKey = PublicKeyEncryption::deriveFileKey(
201            $seed,
202            $recipientDerStrings,
203            $combinedPermissions,
204            16,
205            $encryptMetadata,
206        );
207
208        // Build /Recipients array of PdfString (binary PKCS#7 DER)
209        $recipientStrings = [];
210        foreach ($recipientDerStrings as $der) {
211            $recipientStrings[] = new PdfString($der, hex: true);
212        }
213
214        // Build EncryptDictionary
215        $dict = new EncryptDictionary('Adobe.PubSec', 4);
216        $dict->subFilter = new PdfName('adbe.pkcs7.s5');
217        $dict->encryptMetadata = $encryptMetadata;
218
219        // Set up crypt filter with Recipients
220        $cfDict = new PdfDictionary();
221        $defaultCf = new PdfDictionary();
222        $defaultCf->set('Type', new PdfName('CryptFilter'));
223        $defaultCf->set('CFM', new PdfName('AESV2'));
224        $defaultCf->set('Length', new PdfNumber(16));
225        $defaultCf->set('AuthEvent', new PdfName('DocOpen'));
226        $defaultCf->set('Recipients', new PdfArray($recipientStrings));
227        $cfDict->set('DefaultCryptFilter', $defaultCf);
228        $dict->cf = $cfDict;
229        $dict->stmF = new PdfName('DefaultCryptFilter');
230        $dict->strF = new PdfName('DefaultCryptFilter');
231
232        return new self($encryptionKey, $dict, true, $fileId);
233    }
234
235    /**
236     * Create an encryptor with public-key (certificate-based) AES-256 encryption.
237     *
238     * Uses the Adobe.PubSec handler with /SubFilter /adbe.pkcs7.s5 (V=4, CFM=AESV3).
239     * File encryption key is 32 bytes, derived via SHA-256.
240     *
241     * @param array<array{cert: string, permissions?: int}> $recipients
242     *     Each entry: 'cert' = PEM certificate, 'permissions' = PERM_* bitmask (default PERM_ALL)
243     * @param string $fileId File identifier for the /ID trailer entry
244     */
245    public static function publicKeyAes256(
246        array $recipients,
247        string $fileId,
248    ): self {
249        if ($recipients === []) {
250            throw new \InvalidArgumentException('At least one recipient certificate is required');
251        }
252
253        $seed = random_bytes(20);
254        $encryptMetadata = true;
255
256        $recipientDerStrings = [];
257        $combinedPermissions = self::PERM_ALL | 0xFFFFF000 | 0xC0;
258
259        foreach ($recipients as $r) {
260            $certPem = $r['cert'];
261            $perms = ($r['permissions'] ?? self::PERM_ALL) | 0xFFFFF000 | 0xC0;
262            $combinedPermissions &= $perms;
263
264            $der = PublicKeyEncryption::createEnvelope($seed, $perms, $certPem, $encryptMetadata);
265            $recipientDerStrings[] = $der;
266        }
267
268        // Derive 32-byte file encryption key via SHA-256
269        $encryptionKey = PublicKeyEncryption::deriveFileKey(
270            $seed,
271            $recipientDerStrings,
272            $combinedPermissions,
273            32,
274            $encryptMetadata,
275        );
276
277        $recipientStrings = [];
278        foreach ($recipientDerStrings as $der) {
279            $recipientStrings[] = new PdfString($der, hex: true);
280        }
281
282        // V=4 with AESV3 for AES-256
283        $dict = new EncryptDictionary('Adobe.PubSec', 4);
284        $dict->subFilter = new PdfName('adbe.pkcs7.s5');
285        $dict->length = 256;
286        $dict->encryptMetadata = $encryptMetadata;
287
288        $cfDict = new PdfDictionary();
289        $defaultCf = new PdfDictionary();
290        $defaultCf->set('Type', new PdfName('CryptFilter'));
291        $defaultCf->set('CFM', new PdfName('AESV3'));
292        $defaultCf->set('Length', new PdfNumber(32));
293        $defaultCf->set('AuthEvent', new PdfName('DocOpen'));
294        $defaultCf->set('Recipients', new PdfArray($recipientStrings));
295        $cfDict->set('DefaultCryptFilter', $defaultCf);
296        $dict->cf = $cfDict;
297        $dict->stmF = new PdfName('DefaultCryptFilter');
298        $dict->strF = new PdfName('DefaultCryptFilter');
299
300        return new self($encryptionKey, $dict, true, $fileId, 256);
301    }
302
303    /**
304     * Get the EncryptDictionary to register in the file.
305     */
306    public function getEncryptDictionary(): EncryptDictionary
307    {
308        return $this->encryptDict;
309    }
310
311    /**
312     * Return the minimum PDF version for this encryption: RC4 -> 1.4,
313     * AES-128 -> 1.6, AES-256 -> 2.0. Used by PdfFileWriter to auto-bump
314     * the document version when encryption is registered.
315     */
316    public function getMinimumPdfVersion(): \Phpdftk\Pdf\Core\PdfVersion
317    {
318        return match (true) {
319            $this->aesKeyBits === 256 => \Phpdftk\Pdf\Core\PdfVersion::V2_0,
320            $this->useAes             => \Phpdftk\Pdf\Core\PdfVersion::V1_6,
321            default                   => \Phpdftk\Pdf\Core\PdfVersion::V1_4,
322        };
323    }
324
325    /**
326     * Set the object number of the encrypt dictionary so it's excluded
327     * from encryption.
328     */
329    public function setEncryptDictObjNum(int $objNum): void
330    {
331        $this->encryptDictObjNum = $objNum;
332    }
333
334    /**
335     * Get the file ID used for encryption (needed for the trailer /ID).
336     */
337    public function getFileId(): string
338    {
339        return $this->fileId;
340    }
341
342    /**
343     * Encrypt a PdfObject in-place before serialization.
344     *
345     * For PdfStream: encrypts stream data and strings in the dictionary.
346     * For other PdfObjects: encrypts string values in public properties.
347     * Must NOT be called on the /Encrypt dictionary itself.
348     */
349    public function encryptObject(PdfObject $object): void
350    {
351        if ($object->objectNumber === $this->encryptDictObjNum) {
352            return;
353        }
354
355        $objNum = $object->objectNumber;
356        $genNum = $object->generationNumber;
357
358        if ($object instanceof PdfStream) {
359            $this->encryptStream($object, $objNum, $genNum);
360        } else {
361            $this->encryptObjectProperties($object, $objNum, $genNum);
362        }
363    }
364
365    /**
366     * Encrypt PdfString values in an object's public properties.
367     */
368    private function encryptObjectProperties(PdfObject $object, int $objNum, int $genNum): void
369    {
370        $ref = new \ReflectionObject($object);
371        foreach ($ref->getProperties(\ReflectionProperty::IS_PUBLIC) as $prop) {
372            $value = $prop->getValue($object);
373            if ($value instanceof PdfString) {
374                $prop->setValue($object, $this->encryptString($value, $objNum, $genNum));
375            } elseif ($value instanceof PdfArray) {
376                $prop->setValue($object, $this->encryptArray($value, $objNum, $genNum));
377            } elseif ($value instanceof PdfDictionary) {
378                $this->encryptDictionary($value, $objNum, $genNum);
379            }
380        }
381    }
382
383    /**
384     * Encrypt a PdfStream: encrypt string values in the dictionary
385     * and the stream data itself.
386     */
387    private function encryptStream(PdfStream $stream, int $objNum, int $genNum): void
388    {
389        // Don't encrypt XRef streams
390        $type = $stream->dictionary->get('Type');
391        if ($type instanceof PdfName && $type->value === 'XRef') {
392            return;
393        }
394
395        // Encrypt strings in the dictionary
396        $this->encryptDictionary($stream->dictionary, $objNum, $genNum);
397
398        // Encrypt stream data â€” this must happen BEFORE toPdf() applies
399        // any filter encoding (FlateDecode, etc.), so we encrypt raw data
400        // and let toPdf() compress the encrypted data.
401        // Actually, per PDF spec, compression happens first, THEN encryption.
402        // But since PdfStream::toPdf() handles compression at serialization
403        // time, we need to encrypt the raw data here and the compression
404        // will apply on top during toPdf(). This matches the spec:
405        // data â†’ compress â†’ encrypt â†’ write.
406        //
407        // Wait â€” the spec says decrypt â†’ decompress on read. So on write:
408        // data â†’ compress â†’ encrypt. But PdfStream::toPdf() does
409        // data â†’ compress (via filter). We need to encrypt AFTER compression.
410        // This means we can't encrypt here before toPdf().
411        //
412        // The solution: we'll encrypt the stream data, and the filter
413        // (if any) was already set by applyStreamCompression. Since
414        // applyStreamCompression only sets the filter flag but doesn't
415        // encode until toPdf(), we need a different approach.
416        //
417        // For now: store the encryption info and apply it in a custom
418        // toPdf() override... or we encrypt after serialization.
419        //
420        // Simplest correct approach: encrypt stream data here (raw),
421        // and if the stream has a filter, the filter encoding in toPdf()
422        // will operate on the already-encrypted data. BUT this is wrong
423        // per spec â€” filter encoding should happen BEFORE encryption.
424        //
425        // The real solution: don't use PdfStream::setFilter() for
426        // compression when encryption is active. Instead, manually
427        // compress then encrypt the data, set it directly, and mark
428        // /Filter in the dictionary.
429        if ($stream->data !== '') {
430            $objectKey = $this->deriveObjectKey($objNum, $genNum);
431            $stream->data = $this->encrypt($stream->data, $objectKey);
432        }
433    }
434
435    /**
436     * Encrypt all PdfString values within a dictionary.
437     */
438    public function encryptDictionary(PdfDictionary $dict, int $objNum, int $genNum): void
439    {
440        foreach ($dict->entries as $key => $value) {
441            if ($value instanceof PdfString) {
442                $dict->set($key, $this->encryptString($value, $objNum, $genNum));
443            } elseif ($value instanceof PdfArray) {
444                $dict->set($key, $this->encryptArray($value, $objNum, $genNum));
445            } elseif ($value instanceof PdfDictionary) {
446                $this->encryptDictionary($value, $objNum, $genNum);
447            }
448        }
449    }
450
451    private function encryptString(PdfString $string, int $objNum, int $genNum): PdfString
452    {
453        if ($string->value === '') {
454            return $string;
455        }
456        $objectKey = $this->deriveObjectKey($objNum, $genNum);
457        $encrypted = $this->encrypt($string->value, $objectKey);
458        return new PdfString($encrypted, $string->hex);
459    }
460
461    /**
462     * Encrypt strings within a PdfArray, returning a new array if any
463     * items changed (PdfArray::$items is readonly).
464     */
465    private function encryptArray(PdfArray $array, int $objNum, int $genNum): PdfArray
466    {
467        $changed = false;
468        $items = [];
469        foreach ($array->items as $item) {
470            if ($item instanceof PdfString) {
471                $items[] = $this->encryptString($item, $objNum, $genNum);
472                $changed = true;
473            } elseif ($item instanceof PdfDictionary) {
474                $this->encryptDictionary($item, $objNum, $genNum);
475                $items[] = $item;
476            } elseif ($item instanceof PdfArray) {
477                $items[] = $this->encryptArray($item, $objNum, $genNum);
478            } else {
479                $items[] = $item;
480            }
481        }
482        return $changed ? new PdfArray($items) : $array;
483    }
484
485    private function deriveObjectKey(int $objNum, int $genNum): string
486    {
487        // V=5 R=6: use file encryption key directly (no per-object derivation)
488        if ($this->aesKeyBits === 256) {
489            return $this->encryptionKey;
490        }
491
492        return PdfKeyDerivation::deriveObjectKey(
493            $this->encryptionKey,
494            $objNum,
495            $genNum,
496            $this->useAes,
497        );
498    }
499
500    private function encrypt(string $data, string $key): string
501    {
502        if ($this->useAes) {
503            $aes = new AesCipher($this->aesKeyBits);
504            return $aes->encrypt($data, $key);
505        }
506        $rc4 = new Rc4Cipher();
507        return $rc4->encrypt($data, $key);
508    }
509
510    private static function createStandard(
511        string $userPassword,
512        string $ownerPassword,
513        string $fileId,
514        int $permissions,
515        int $keyLengthBits,
516        int $v,
517        int $r,
518        bool $useAes,
519    ): self {
520        // Ensure required permission bits are set (bits 7-8 must be 1, bits 13-32 must be 1)
521        $p = $permissions | 0xFFFFF000 | 0xC0;
522
523        // Compute /O value
524        $oValue = PdfKeyDerivation::computeOwnerKey($ownerPassword, $userPassword, $keyLengthBits);
525
526        // Compute file encryption key
527        $encryptionKey = PdfKeyDerivation::computeFileEncryptionKey(
528            $userPassword,
529            $oValue,
530            $p,
531            $fileId,
532            $keyLengthBits,
533            $r,
534        );
535
536        // Compute /U value
537        $uValue = PdfKeyDerivation::computeUserKey($encryptionKey, $fileId, $r);
538
539        // Build the EncryptDictionary
540        $dict = new EncryptDictionary('Standard', $v);
541        $dict->r = $r;
542        $dict->length = $keyLengthBits;
543        $dict->o = new PdfString($oValue, hex: true);
544        $dict->u = new PdfString($uValue, hex: true);
545        $dict->p = $p;
546
547        if ($v === 4) {
548            // V=4: set up crypt filters
549            $cfDict = new PdfDictionary();
550            $stdCf = new PdfDictionary();
551            $stdCf->set('Type', new PdfName('CryptFilter'));
552            $stdCf->set('CFM', new PdfName($useAes ? 'AESV2' : 'V2'));
553            $stdCf->set('Length', new PdfNumber(16));
554            $cfDict->set('StdCF', $stdCf);
555            $dict->cf = $cfDict;
556            $dict->stmF = new PdfName('StdCF');
557            $dict->strF = new PdfName('StdCF');
558        }
559
560        return new self($encryptionKey, $dict, $useAes, $fileId);
561    }
562
563    /**
564     * Create an R=6 (AES-256) encryptor per ISO 32000-2 Â§7.6.4.3.3.
565     */
566    private static function createR6(
567        string $userPassword,
568        string $ownerPassword,
569        string $fileId,
570        int $permissions,
571    ): self {
572        // Ensure required permission bits are set
573        $p = $permissions | 0xFFFFF000 | 0xC0;
574
575        // SASLprep + truncate to 127 bytes
576        $userPw = PdfKeyDerivation::preparePasswordR6($userPassword);
577        $ownerPw = PdfKeyDerivation::preparePasswordR6($ownerPassword);
578
579        // Generate 32-byte random file encryption key
580        $fileEncryptionKey = random_bytes(32);
581
582        // Compute /U (48 bytes) and /UE (32 bytes)
583        $uResult = PdfKeyDerivation::computeUValueR6($userPw, $fileEncryptionKey);
584        $uValue = $uResult['u'];
585        $ueValue = $uResult['ue'];
586
587        // Compute /O (48 bytes) and /OE (32 bytes)
588        $oResult = PdfKeyDerivation::computeOValueR6($ownerPw, $fileEncryptionKey, $uValue);
589        $oValue = $oResult['o'];
590        $oeValue = $oResult['oe'];
591
592        // Compute /Perms (16 bytes)
593        $permsValue = PdfKeyDerivation::computePermsR6($p, $fileEncryptionKey);
594
595        // Build EncryptDictionary
596        $dict = new EncryptDictionary('Standard', 5);
597        $dict->r = 6;
598        $dict->length = 256;
599        $dict->o = new PdfString($oValue, hex: true);
600        $dict->u = new PdfString($uValue, hex: true);
601        $dict->oe = new PdfString($oeValue, hex: true);
602        $dict->ue = new PdfString($ueValue, hex: true);
603        $dict->p = $p;
604        $dict->perms = new PdfString($permsValue, hex: true);
605        $dict->encryptMetadata = true;
606
607        // Set up crypt filters for AESV3
608        $cfDict = new PdfDictionary();
609        $stdCf = new PdfDictionary();
610        $stdCf->set('Type', new PdfName('CryptFilter'));
611        $stdCf->set('CFM', new PdfName('AESV3'));
612        $stdCf->set('Length', new PdfNumber(32));
613        $cfDict->set('StdCF', $stdCf);
614        $dict->cf = $cfDict;
615        $dict->stmF = new PdfName('StdCF');
616        $dict->strF = new PdfName('StdCF');
617
618        return new self($fileEncryptionKey, $dict, true, $fileId, 256);
619    }
620}