Code Coverage
 
Lines
Functions and Methods
Classes and Traits
Total
100.00% covered (success)
100.00%
159 / 159
100.00% covered (success)
100.00%
15 / 15
CRAP
100.00% covered (success)
100.00%
1 / 1
PdfKeyDerivation
100.00% covered (success)
100.00%
159 / 159
100.00% covered (success)
100.00%
15 / 15
47
100.00% covered (success)
100.00%
1 / 1
 deriveObjectKey
100.00% covered (success)
100.00%
12 / 12
100.00% covered (success)
100.00%
1 / 1
2
 computeOwnerKey
100.00% covered (success)
100.00%
17 / 17
100.00% covered (success)
100.00%
1 / 1
6
 computeFileEncryptionKey
100.00% covered (success)
100.00%
12 / 12
100.00% covered (success)
100.00%
1 / 1
5
 computeUserKey
100.00% covered (success)
100.00%
12 / 12
100.00% covered (success)
100.00%
1 / 1
4
 authenticateUserPassword
100.00% covered (success)
100.00%
16 / 16
100.00% covered (success)
100.00%
1 / 1
4
 authenticateOwnerPassword
100.00% covered (success)
100.00%
26 / 26
100.00% covered (success)
100.00%
1 / 1
6
 computeHashR6
100.00% covered (success)
100.00%
17 / 17
100.00% covered (success)
100.00%
1 / 1
7
 computeUValueR6
100.00% covered (success)
100.00%
8 / 8
100.00% covered (success)
100.00%
1 / 1
1
 computeOValueR6
100.00% covered (success)
100.00%
9 / 9
100.00% covered (success)
100.00%
1 / 1
1
 computePermsR6
100.00% covered (success)
100.00%
7 / 7
100.00% covered (success)
100.00%
1 / 1
2
 authenticateUserPasswordR6
100.00% covered (success)
100.00%
9 / 9
100.00% covered (success)
100.00%
1 / 1
3
 authenticateOwnerPasswordR6
100.00% covered (success)
100.00%
10 / 10
100.00% covered (success)
100.00%
1 / 1
3
 saslPrep
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 pad
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 preparePasswordR6
100.00% covered (success)
100.00%
2 / 2
100.00% covered (success)
100.00%
1 / 1
1
1<?php
2
3declare(strict_types=1);
4
5namespace Phpdftk\Crypt;
6
7/**
8 * PDF encryption key derivation — ISO 32000-2 §7.6.
9 *
10 * Covers the Standard security handler (R=2/3/4 with RC4/AES-128
11 * and R=6 with AES-256).
12 */
13final class PdfKeyDerivation
14{
15    /** Standard 32-byte padding string per PDF spec §7.6.3.3. */
16    public const PADDING = "\x28\xBF\x4E\x5E\x4E\x75\x8A\x41\x64\x00\x4E\x56\xFF\xFA\x01\x08"
17        . "\x2E\x2E\x00\xB6\xD0\x68\x3E\x80\x2F\x0C\xA9\xFE\x64\x53\x69\x7A";
18
19    /**
20     * Derive an object encryption key per PDF spec §7.6.3.3.
21     */
22    public static function deriveObjectKey(
23        string $encryptionKey,
24        int $objectNumber,
25        int $generationNumber,
26        bool $aes = false,
27    ): string {
28        $input = $encryptionKey
29            . chr($objectNumber & 0xFF)
30            . chr(($objectNumber >> 8) & 0xFF)
31            . chr(($objectNumber >> 16) & 0xFF)
32            . chr($generationNumber & 0xFF)
33            . chr(($generationNumber >> 8) & 0xFF);
34
35        if ($aes) {
36            $input .= "\x73\x41\x6C\x54"; // "sAlT"
37        }
38
39        $hash = md5($input, true);
40        $keyLen = min(strlen($encryptionKey) + 5, 16);
41        $keyLen = max(5, $keyLen);
42
43        return substr($hash, 0, $keyLen);
44    }
45
46    /**
47     * Compute the owner key (/O) — §7.6.3.4 (R=2/3/4).
48     */
49    public static function computeOwnerKey(
50        string $ownerPassword,
51        string $userPassword,
52        int $keyLength,
53    ): string {
54        $padOwner = self::pad($ownerPassword);
55        $padUser = self::pad($userPassword);
56
57        $hash = md5($padOwner, true);
58        if ($keyLength > 40) {
59            for ($i = 0; $i < 50; $i++) {
60                $hash = md5($hash, true);
61            }
62        }
63
64        $rc4KeyLen = $keyLength / 8;
65        $rc4Key = substr($hash, 0, $rc4KeyLen);
66
67        $rc4 = new Rc4Cipher();
68        $ownerKey = $rc4->encrypt($padUser, $rc4Key);
69
70        if ($keyLength > 40) {
71            for ($i = 1; $i <= 19; $i++) {
72                $iterKey = '';
73                for ($j = 0; $j < $rc4KeyLen; $j++) {
74                    $iterKey .= chr(ord($rc4Key[$j]) ^ $i);
75                }
76                $ownerKey = $rc4->encrypt($ownerKey, $iterKey);
77            }
78        }
79
80        return substr($ownerKey, 0, 32);
81    }
82
83    /**
84     * Compute the file encryption key from the user password — §7.6.3.3.
85     *
86     * @param string $userPassword  The user password
87     * @param string $oValue        The /O value from the encrypt dictionary (32 bytes)
88     * @param int    $pValue        The /P permissions value (signed 32-bit)
89     * @param string $fileId        The first element of the /ID array
90     * @param int    $keyLengthBits Key length in bits (40, 56, 64, 80, 96, 128)
91     * @param int    $revision      Revision (R=2..4)
92     * @param bool   $encryptMetadata Whether metadata is encrypted (R=4 only)
93     */
94    public static function computeFileEncryptionKey(
95        string $userPassword,
96        string $oValue,
97        int $pValue,
98        string $fileId,
99        int $keyLengthBits = 128,
100        int $revision = 3,
101        bool $encryptMetadata = true,
102    ): string {
103        $padded = self::pad($userPassword);
104
105        $input = $padded . $oValue;
106        // /P as a 32-bit signed LE value
107        $input .= pack('V', $pValue);
108        $input .= $fileId;
109
110        if ($revision >= 4 && !$encryptMetadata) {
111            $input .= "\xFF\xFF\xFF\xFF";
112        }
113
114        $hash = md5($input, true);
115
116        $keyLen = $keyLengthBits / 8;
117
118        if ($revision >= 3) {
119            for ($i = 0; $i < 50; $i++) {
120                $hash = md5(substr($hash, 0, $keyLen), true);
121            }
122        }
123
124        return substr($hash, 0, $keyLen);
125    }
126
127    /**
128     * Compute the user key (/U) — §7.6.3.4.
129     *
130     * @param string $encryptionKey The file encryption key
131     * @param string $fileId        The first element of /ID
132     * @param int    $revision      Revision (R=2..4)
133     */
134    public static function computeUserKey(
135        string $encryptionKey,
136        string $fileId,
137        int $revision = 3,
138    ): string {
139        $rc4 = new Rc4Cipher();
140
141        if ($revision === 2) {
142            return $rc4->encrypt(self::PADDING, $encryptionKey);
143        }
144
145        // R >= 3: MD5 hash of padding + file ID, then RC4 with 20 iterations
146        $hash = md5(self::PADDING . $fileId, true);
147        $result = $rc4->encrypt($hash, $encryptionKey);
148
149        $keyLen = strlen($encryptionKey);
150        for ($i = 1; $i <= 19; $i++) {
151            $iterKey = '';
152            for ($j = 0; $j < $keyLen; $j++) {
153                $iterKey .= chr(ord($encryptionKey[$j]) ^ $i);
154            }
155            $result = $rc4->encrypt($result, $iterKey);
156        }
157
158        // Pad to 32 bytes with arbitrary data
159        return str_pad($result, 32, "\x00");
160    }
161
162    /**
163     * Authenticate a user password — returns the file encryption key
164     * if the password is valid, null otherwise.
165     */
166    public static function authenticateUserPassword(
167        string $password,
168        string $oValue,
169        string $uValue,
170        int $pValue,
171        string $fileId,
172        int $keyLengthBits = 128,
173        int $revision = 3,
174        bool $encryptMetadata = true,
175    ): ?string {
176        $key = self::computeFileEncryptionKey(
177            $password,
178            $oValue,
179            $pValue,
180            $fileId,
181            $keyLengthBits,
182            $revision,
183            $encryptMetadata,
184        );
185
186        $computedU = self::computeUserKey($key, $fileId, $revision);
187
188        if ($revision === 2) {
189            if ($computedU === $uValue) {
190                return $key;
191            }
192        } else {
193            // R >= 3: compare first 16 bytes only
194            if (substr($computedU, 0, 16) === substr($uValue, 0, 16)) {
195                return $key;
196            }
197        }
198
199        return null;
200    }
201
202    /**
203     * Authenticate an owner password — returns the file encryption key
204     * if the password is valid, null otherwise.
205     */
206    public static function authenticateOwnerPassword(
207        string $ownerPassword,
208        string $oValue,
209        string $uValue,
210        int $pValue,
211        string $fileId,
212        int $keyLengthBits = 128,
213        int $revision = 3,
214        bool $encryptMetadata = true,
215    ): ?string {
216        // Derive the RC4 key from the owner password
217        $padOwner = self::pad($ownerPassword);
218        $hash = md5($padOwner, true);
219        if ($keyLengthBits > 40) {
220            for ($i = 0; $i < 50; $i++) {
221                $hash = md5($hash, true);
222            }
223        }
224        $rc4KeyLen = $keyLengthBits / 8;
225        $rc4Key = substr($hash, 0, $rc4KeyLen);
226
227        // Decrypt /O to recover the user password
228        $rc4 = new Rc4Cipher();
229        if ($revision === 2) {
230            $userPassword = $rc4->decrypt($oValue, $rc4Key);
231        } else {
232            $userPassword = $oValue;
233            for ($i = 19; $i >= 0; $i--) {
234                $iterKey = '';
235                for ($j = 0; $j < $rc4KeyLen; $j++) {
236                    $iterKey .= chr(ord($rc4Key[$j]) ^ $i);
237                }
238                $userPassword = $rc4->decrypt($userPassword, $iterKey);
239            }
240        }
241
242        // Now authenticate using the recovered user password
243        return self::authenticateUserPassword(
244            $userPassword,
245            $oValue,
246            $uValue,
247            $pValue,
248            $fileId,
249            $keyLengthBits,
250            $revision,
251            $encryptMetadata,
252        );
253    }
254
255    // -----------------------------------------------------------------------
256    // R=6 (AES-256) — ISO 32000-2 §7.6.4.3.3 / §7.6.4.3.4
257    // -----------------------------------------------------------------------
258
259    /**
260     * R=6 iterative hash algorithm — ISO 32000-2 §7.6.4.3.4.
261     *
262     * @param string $password  UTF-8 password (already SASLprep'd, truncated to 127 bytes)
263     * @param string $salt      8-byte salt
264     * @param string $userKey   First 48 bytes of /U value (empty for user password validation)
265     */
266    public static function computeHashR6(string $password, string $salt, string $userKey = ''): string
267    {
268        $k = hash('sha256', $password . $salt . $userKey, true);
269
270        $round = 0;
271        while (true) {
272            // Build K1 = (password + K + userKey) repeated 64 times
273            $k1Single = $password . $k . $userKey;
274            $k1 = str_repeat($k1Single, 64);
275
276            // AES-128-CBC encrypt K1 with key=K[0:16], IV=K[16:32]
277            $aesKey = substr($k, 0, 16);
278            $aesIv = substr($k, 16, 16);
279            $e = openssl_encrypt($k1, 'AES-128-CBC', $aesKey, OPENSSL_RAW_DATA | OPENSSL_NO_PADDING, $aesIv);
280
281            // Pick hash based on first byte of E mod 3
282            $remainder = ord($e[0]) % 3;
283            $k = match ($remainder) {
284                0 => hash('sha256', $e, true),
285                1 => hash('sha384', $e, true),
286                2 => hash('sha512', $e, true),
287            };
288
289            // Continue while round < 64, or while last byte of E > (round - 32)
290            if ($round >= 63 && ord($e[strlen($e) - 1]) <= $round - 32) {
291                break;
292            }
293            $round++;
294        }
295
296        return substr($k, 0, 32);
297    }
298
299    /**
300     * Compute /U and /UE values for R=6 — ISO 32000-2 §7.6.4.3.3 (Algorithm 2.A step a).
301     *
302     * @return array{u: string, ue: string} U is 48 bytes, UE is 32 bytes
303     */
304    public static function computeUValueR6(string $password, string $fileEncryptionKey): array
305    {
306        $validationSalt = random_bytes(8);
307        $keySalt = random_bytes(8);
308
309        $hash = self::computeHashR6($password, $validationSalt);
310        $u = $hash . $validationSalt . $keySalt; // 48 bytes
311
312        // UE = AES-256-CBC encrypt the file encryption key
313        $ueKey = self::computeHashR6($password, $keySalt);
314        $iv = str_repeat("\x00", 16);
315        $ue = openssl_encrypt($fileEncryptionKey, 'AES-256-CBC', $ueKey, OPENSSL_RAW_DATA | OPENSSL_NO_PADDING, $iv);
316
317        return ['u' => $u, 'ue' => $ue];
318    }
319
320    /**
321     * Compute /O and /OE values for R=6 — ISO 32000-2 §7.6.4.3.3 (Algorithm 2.A step b).
322     *
323     * @param string $uValue First 48 bytes of the /U value
324     * @return array{o: string, oe: string} O is 48 bytes, OE is 32 bytes
325     */
326    public static function computeOValueR6(string $password, string $fileEncryptionKey, string $uValue): array
327    {
328        $validationSalt = random_bytes(8);
329        $keySalt = random_bytes(8);
330
331        $u48 = substr($uValue, 0, 48);
332        $hash = self::computeHashR6($password, $validationSalt, $u48);
333        $o = $hash . $validationSalt . $keySalt; // 48 bytes
334
335        // OE = AES-256-CBC encrypt the file encryption key
336        $oeKey = self::computeHashR6($password, $keySalt, $u48);
337        $iv = str_repeat("\x00", 16);
338        $oe = openssl_encrypt($fileEncryptionKey, 'AES-256-CBC', $oeKey, OPENSSL_RAW_DATA | OPENSSL_NO_PADDING, $iv);
339
340        return ['o' => $o, 'oe' => $oe];
341    }
342
343    /**
344     * Compute /Perms value for R=6 — ISO 32000-2 §7.6.4.3.3 (Algorithm 2.A step c).
345     */
346    public static function computePermsR6(int $permissions, string $fileEncryptionKey, bool $encryptMetadata = true): string
347    {
348        // Build 16-byte buffer
349        $buf = pack('V', $permissions);       // P as 4 bytes LE
350        $buf .= "\xFF\xFF\xFF\xFF";           // 4 bytes 0xFF
351        $buf .= $encryptMetadata ? 'T' : 'F'; // 1 byte
352        $buf .= 'adb';                        // 3 bytes
353        $buf .= random_bytes(4);              // 4 random bytes
354
355        // AES-256-ECB encrypt
356        $result = openssl_encrypt($buf, 'AES-256-ECB', $fileEncryptionKey, OPENSSL_RAW_DATA | OPENSSL_NO_PADDING);
357
358        return $result;
359    }
360
361    /**
362     * Authenticate a user password for R=6 — returns file encryption key or null.
363     *
364     * @param string $password  UTF-8 password (already SASLprep'd, truncated to 127 bytes)
365     * @param string $uValue    48-byte /U value
366     * @param string $ueValue   32-byte /UE value
367     */
368    public static function authenticateUserPasswordR6(string $password, string $uValue, string $ueValue): ?string
369    {
370        $validationSalt = substr($uValue, 32, 8);
371        $keySalt = substr($uValue, 40, 8);
372
373        $hash = self::computeHashR6($password, $validationSalt);
374
375        // Compare hash with U[0:32]
376        if (!hash_equals(substr($uValue, 0, 32), $hash)) {
377            return null;
378        }
379
380        // Decrypt file encryption key from UE
381        $decryptKey = self::computeHashR6($password, $keySalt);
382        $iv = str_repeat("\x00", 16);
383        $fileKey = openssl_decrypt($ueValue, 'AES-256-CBC', $decryptKey, OPENSSL_RAW_DATA | OPENSSL_NO_PADDING, $iv);
384
385        return $fileKey !== false ? $fileKey : null;
386    }
387
388    /**
389     * Authenticate an owner password for R=6 — returns file encryption key or null.
390     *
391     * @param string $password  UTF-8 password (already SASLprep'd, truncated to 127 bytes)
392     * @param string $oValue    48-byte /O value
393     * @param string $oeValue   32-byte /OE value
394     * @param string $uValue    48-byte /U value
395     */
396    public static function authenticateOwnerPasswordR6(string $password, string $oValue, string $oeValue, string $uValue): ?string
397    {
398        $validationSalt = substr($oValue, 32, 8);
399        $keySalt = substr($oValue, 40, 8);
400        $u48 = substr($uValue, 0, 48);
401
402        $hash = self::computeHashR6($password, $validationSalt, $u48);
403
404        // Compare hash with O[0:32]
405        if (!hash_equals(substr($oValue, 0, 32), $hash)) {
406            return null;
407        }
408
409        // Decrypt file encryption key from OE
410        $decryptKey = self::computeHashR6($password, $keySalt, $u48);
411        $iv = str_repeat("\x00", 16);
412        $fileKey = openssl_decrypt($oeValue, 'AES-256-CBC', $decryptKey, OPENSSL_RAW_DATA | OPENSSL_NO_PADDING, $iv);
413
414        return $fileKey !== false ? $fileKey : null;
415    }
416
417    /**
418     * Normalize a password via SASLprep (RFC 4013).
419     *
420     * Required for PDF 2.0 encryption (R=6, AES-256) per ISO 32000-2 §7.6.4.3.2.
421     */
422    public static function saslPrep(string $password): string
423    {
424        return SaslPrep::prepare($password);
425    }
426
427    /**
428     * Pad or truncate a password to 32 bytes using the standard padding.
429     */
430    public static function pad(string $password): string
431    {
432        return substr($password . self::PADDING, 0, 32);
433    }
434
435    /**
436     * Prepare a password for R=6: SASLprep + truncate to 127 bytes.
437     */
438    public static function preparePasswordR6(string $password): string
439    {
440        $prepared = self::saslPrep($password);
441        return substr($prepared, 0, 127);
442    }
443}