Code Coverage
 
Lines
Functions and Methods
Classes and Traits
Total
97.16% covered (success)
97.16%
137 / 141
75.00% covered (success)
75.00%
12 / 16
CRAP
0.00% covered (danger)
0.00%
0 / 1
StandardSecurityHandler
97.16% covered (success)
97.16%
137 / 141
75.00% covered (success)
75.00%
12 / 16
46
0.00% covered (danger)
0.00%
0 / 1
 buildRevision6
100.00% covered (success)
100.00%
17 / 17
100.00% covered (success)
100.00%
1 / 1
1
 openRevision6
100.00% covered (success)
100.00%
14 / 14
100.00% covered (success)
100.00%
1 / 1
7
 buildRevision4
100.00% covered (success)
100.00%
7 / 7
100.00% covered (success)
100.00%
1 / 1
1
 openRevision4
100.00% covered (success)
100.00%
20 / 20
100.00% covered (success)
100.00%
1 / 1
6
 recoverPaddedUserPassword
100.00% covered (success)
100.00%
8 / 8
100.00% covered (success)
100.00%
1 / 1
3
 deriveRevision4FileKeyFromUserPassword
100.00% covered (success)
100.00%
3 / 3
100.00% covered (success)
100.00%
1 / 1
1
 deriveRevision4FileKey
100.00% covered (success)
100.00%
11 / 11
100.00% covered (success)
100.00%
1 / 1
3
 padPassword
100.00% covered (success)
100.00%
2 / 2
100.00% covered (success)
100.00%
1 / 1
1
 computeORevision4
100.00% covered (success)
100.00%
8 / 8
100.00% covered (success)
100.00%
1 / 1
3
 computeURevision4
100.00% covered (success)
100.00%
4 / 4
100.00% covered (success)
100.00%
1 / 1
2
 xorKey
100.00% covered (success)
100.00%
4 / 4
100.00% covered (success)
100.00%
1 / 1
2
 hash2B
95.24% covered (success)
95.24%
20 / 21
0.00% covered (danger)
0.00%
0 / 1
9
 wrapFileKey
83.33% covered (success)
83.33%
5 / 6
0.00% covered (danger)
0.00%
0 / 1
2.02
 unwrapFileKey
83.33% covered (success)
83.33%
5 / 6
0.00% covered (danger)
0.00%
0 / 1
2.02
 computePerms
88.89% covered (success)
88.89%
8 / 9
0.00% covered (danger)
0.00%
0 / 1
2.01
 preparePassword
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
1<?php
2declare(strict_types=1);
3/**
4 * Pop PHP Framework (https://www.popphp.org/)
5 *
6 * @link       https://github.com/popphp/popphp-framework
7 * @author     Nick Sagona, III <nick@popphp.org>
8 * @copyright  Copyright (c) 2009-2026 Nick Sagona, III
9 * @license    https://www.popphp.org/license     New BSD License
10 */
11
12/**
13 * @namespace
14 */
15namespace Pop\Pdf\Build\Security;
16
17use Pop\Pdf\Document\Security;
18
19/**
20 * PDF Standard Security Handler - password and key derivation for the
21 * /Encrypt dictionary.
22 *
23 * The build (write) direction turns a Document\Security into the /O, /U,
24 * /OE, /UE, /P and /Perms values a conforming reader validates a password
25 * against, plus the File Encryption Key those values wrap.
26 *
27 * The open (read) direction runs the same primitives backwards: given an
28 * /Encrypt dictionary read off disk and a candidate password, it verifies
29 * the password and recovers that File Encryption Key.
30 *
31 * @category   Pop
32 * @package    Pop\Pdf
33 * @author     Nick Sagona, III <nick@popphp.org>
34 * @copyright  Copyright (c) 2009-2026 Nick Sagona, III
35 * @license    https://www.popphp.org/license     New BSD License
36 * @version    6.2.0
37 */
38class StandardSecurityHandler
39{
40
41    /**
42     * Maximum password length in bytes. ISO 32000-2, 7.6.4.3.3: a password
43     * longer than 127 bytes is truncated, and readers do the same, so the
44     * truncation has to happen here or long passwords would not round-trip.
45     * @var int
46     */
47    const MAX_PASSWORD_LENGTH = 127;
48
49    /**
50     * The 32-byte password padding string of ISO 32000-1 Annex C, Algorithm 2
51     * step (a). Revision 2-4 passwords are padded out to exactly 32 bytes
52     * with the leading bytes of this string (or truncated to 32 bytes if
53     * longer), so that an empty password is still a full 32-byte input.
54     *
55     * This scheme belongs to revisions 2-4 only. Revision 6 does not pad at
56     * all - it takes the raw UTF-8 password bytes truncated to 127 - so
57     * nothing here is shared with buildRevision6()'s preparePassword().
58     * @var string
59     */
60    const PADDING =
61        "\x28\xBF\x4E\x5E\x4E\x75\x8A\x41\x64\x00\x4E\x56\xFF\xFA\x01\x08" .
62        "\x2E\x2E\x00\xB6\xD0\x68\x3E\x80\x2F\x0C\xA9\xFE\x64\x53\x69\x7A";
63
64    /**
65     * Number of key-strengthening rounds performed by revision 3+ of
66     * Algorithms 2 and 3.
67     * @var int
68     */
69    const KEY_ROUNDS = 50;
70
71    /**
72     * Number of XOR'd-key RC4 re-encryption rounds performed by revision 3+
73     * of Algorithms 3 and 5, with the counter running 1 to 19 inclusive.
74     * @var int
75     */
76    const RC4_ROUNDS = 19;
77
78    /**
79     * Build the /Encrypt dictionary fields and File Encryption Key for
80     * revision 6 (AES-256, PDF 2.0). ISO 32000-2 Annex C, Algorithms 2.A,
81     * 2.B, 8, 9, 10.
82     *
83     * The File Encryption Key is random and independent of both passwords;
84     * each password only ever wraps it (in /UE and /OE respectively), which
85     * is why the owner password can open the document without either
86     * password being derivable from the other.
87     *
88     * $fileId is deliberately unused: unlike revision 4, revision 6 does not
89     * mix the document /ID into the key derivation at all. It stays in the
90     * signature so the revision 4 and 6 builders can be called uniformly.
91     *
92     * @param  Security $security
93     * @param  string   $fileId raw bytes of the PDF's first /ID element (unused for revision 6)
94     * @throws Exception
95     * @return array{fileKey: string, dict: array<string, string|int>}
96     */
97    public static function buildRevision6(Security $security, string $fileId): array
98    {
99        $fileKey       = random_bytes(32);
100        $userPassword  = self::preparePassword((string)$security->getUserPassword());
101        $ownerPassword = self::preparePassword($security->getEffectiveOwnerPassword());
102
103        // Algorithm 8 - /U and /UE. The validation salt proves the password;
104        // the key salt derives the wrapping key for the file key.
105        $userValidationSalt = random_bytes(8);
106        $userKeySalt        = random_bytes(8);
107        $u  = self::hash2B($userPassword, $userValidationSalt, '') . $userValidationSalt . $userKeySalt;
108        $ue = self::wrapFileKey(self::hash2B($userPassword, $userKeySalt, ''), $fileKey);
109
110        // Algorithm 9 - /O and /OE. Both owner hashes additionally take the
111        // full 48-byte /U string as input, which binds /O to /U and is why
112        // /U must be computed first.
113        $ownerValidationSalt = random_bytes(8);
114        $ownerKeySalt        = random_bytes(8);
115        $o  = self::hash2B($ownerPassword, $ownerValidationSalt, $u) . $ownerValidationSalt . $ownerKeySalt;
116        $oe = self::wrapFileKey(self::hash2B($ownerPassword, $ownerKeySalt, $u), $fileKey);
117
118        $p     = $security->getPermissions()->toPValue();
119        $perms = self::computePerms($fileKey, $p);
120
121        return [
122            'fileKey' => $fileKey,
123            'dict'    => ['O' => $o, 'U' => $u, 'OE' => $oe, 'UE' => $ue, 'P' => $p, 'Perms' => $perms],
124        ];
125    }
126
127    /**
128     * Verify a candidate password against a revision 6 (AES-256) /Encrypt
129     * dictionary and recover the File Encryption Key. ISO 32000-2 Annex C,
130     * Algorithm 2.A, by way of Algorithms 11 (user) and 12 (owner) - the
131     * exact inverse of buildRevision6().
132     *
133     * Each of /U and /O is a 48-byte string laid out by Algorithms 8 and 9
134     * as hash (bytes 0-31), validation salt (32-39), key salt (40-47). The
135     * validation salt proves the password; the key salt then derives the
136     * key that unwraps the file key out of /UE or /OE. Note the two salts
137     * are NOT interchangeable - using the validation salt to unwrap would
138     * still yield 32 plausible bytes and no error at all, just an unusable
139     * key, so the offsets are load-bearing.
140     *
141     * Both owner hashes additionally take the full 48-byte /U string as
142     * input (that is what binds /O to /U); the user hashes take an empty
143     * string there.
144     *
145     * The user password is tried first and the owner password second;
146     * Algorithm 2.A states the opposite order. For any conforming file the
147     * order cannot change the result, because /UE and /OE are required to
148     * wrap one and the same file key (buildRevision6() passes the identical
149     * key to both) - so it would matter only to a caller that needed to know
150     * WHICH permission level was unlocked, which this method's return type
151     * cannot express anyway. That is a property of the file being conforming,
152     * not an identity: a malformed or hostile file whose /UE and /OE wrap
153     * different keys has no single right answer, and neither ordering
154     * produces one.
155     *
156     * @param  array<string, string|int> $encryptDict must contain O, U, OE, UE (as built by buildRevision6())
157     * @param  string                    $candidatePassword
158     * @throws Exception
159     * @return string 32 raw bytes
160     */
161    public static function openRevision6(array $encryptDict, string $candidatePassword): string
162    {
163        // Truncated to 48 bytes: the spec fixes /U and /O at exactly that
164        // length, but some producers pad them out to 127 (a leftover of the
165        // pre-standard Adobe extension level 3 revision), and every reader
166        // worth interoperating with ignores the excess.
167        $u  = substr((string)($encryptDict['U'] ?? ''), 0, 48);
168        $o  = substr((string)($encryptDict['O'] ?? ''), 0, 48);
169        $ue = (string)($encryptDict['UE'] ?? '');
170        $oe = (string)($encryptDict['OE'] ?? '');
171
172        if ((strlen($u) != 48) || (strlen($o) != 48) || (strlen($ue) != 32) || (strlen($oe) != 32)) {
173            throw new Exception(
174                'Error: The encryption dictionary is malformed - /U and /O must be 48 bytes and /UE and /OE 32 bytes.'
175            );
176        }
177
178        $password = self::preparePassword($candidatePassword);
179
180        // Algorithm 11 - the user password.
181        if (hash_equals(substr($u, 0, 32), self::hash2B($password, substr($u, 32, 8), ''))) {
182            return self::unwrapFileKey(self::hash2B($password, substr($u, 40, 8), ''), $ue);
183        }
184
185        // Algorithm 12 - the owner password.
186        if (hash_equals(substr($o, 0, 32), self::hash2B($password, substr($o, 32, 8), $u))) {
187            return self::unwrapFileKey(self::hash2B($password, substr($o, 40, 8), $u), $oe);
188        }
189
190        throw new Exception('Error: The password provided is incorrect for this encrypted PDF.');
191    }
192
193    /**
194     * Build the /Encrypt dictionary fields and File Encryption Key for
195     * revision 4 (AES-128, PDF 1.6/1.7). ISO 32000-1 Annex C, Algorithms
196     * 2, 3 and 5. Algorithms 3/5 are specified in terms of RC4 regardless
197     * of the content cipher - RC4 here never touches page/stream content.
198     *
199     * Unlike revision 6, the file key is not random: it is derived from the
200     * user password, /O, /P and the document /ID, so every one of those has
201     * to be settled before the key exists. That forces a strict order -
202     * /O first (it feeds the key), then the key, then /U (it is the key,
203     * obfuscated). Producing them in any other order cannot work.
204     *
205     * The owner password never encrypts anything directly. /O is the padded
206     * USER password encrypted under a key derived from the owner password,
207     * so a reader given the owner password peels /O back to the user
208     * password and then follows the ordinary user path from there.
209     *
210     * @param  Security $security
211     * @param  string   $fileId raw bytes of the PDF's first /ID element
212     * @return array{fileKey: string, dict: array<string, string|int>}
213     */
214    public static function buildRevision4(Security $security, string $fileId): array
215    {
216        $userPassword  = (string)$security->getUserPassword();
217        $ownerPassword = $security->getEffectiveOwnerPassword();
218        $p             = $security->getPermissions()->toPValue();
219
220        $o       = self::computeORevision4($ownerPassword, $userPassword);
221        $fileKey = self::deriveRevision4FileKey(self::padPassword($userPassword), $o, $p, $fileId);
222        $u       = self::computeURevision4($fileKey, $fileId);
223
224        return ['fileKey' => $fileKey, 'dict' => ['O' => $o, 'U' => $u, 'P' => $p]];
225    }
226
227    /**
228     * Verify a candidate password against a revision 4 (AES-128) /Encrypt
229     * dictionary and recover the File Encryption Key. ISO 32000-1 Annex C,
230     * Algorithm 6 (the candidate tried as a USER password), falling back to
231     * Algorithm 7 (tried as an OWNER password) - the exact inverse of
232     * buildRevision4().
233     *
234     * The two paths differ only in how they arrive at the padded user
235     * password that Algorithm 2 consumes. As a user password the candidate
236     * IS that password, padded. As an owner password it is only the key that
237     * unlocks /O, which is where Algorithm 3 stored the padded user password;
238     * once recovered, the owner path is indistinguishable from the user path.
239     * That is the whole mechanism by which an owner password opens a
240     * document without being the user password.
241     *
242     * Only the FIRST 16 bytes of /U are compared. Algorithm 5 fixes /U at 32
243     * bytes but specifies only its first 16 - the remainder is arbitrary
244     * padding. buildRevision4() writes zeros there; qpdf writes random bytes;
245     * both are conforming. Comparing all 32 would pass every round-trip
246     * against this library's own output and then reject every file written by
247     * anyone else, so the truncation is load-bearing rather than cosmetic.
248     *
249     * Unlike openRevision6(), /O and /U are required to be exactly 32 bytes
250     * with no leniency. The over-long /U and /O this class tolerates for
251     * revision 6 are an artifact of the pre-standard Adobe extension level 3
252     * AES-256 revision, which postdates revision 4 entirely; no revision 4
253     * producer is known to pad them, and /O is fed WHOLE into Algorithm 2's
254     * digest, so silently accepting a wrong length would yield a wrong key
255     * and an "incorrect password" error pointing at the wrong problem.
256     *
257     * /EncryptMetadata, if present and explicitly false, changes the key
258     * itself (Algorithm 2 step (f)), not just how metadata is handled - so it
259     * is read from the dictionary here rather than being a caller's concern.
260     * Omitting it derives a different, wrong key, which surfaces as a bogus
261     * "the password provided is incorrect" for a perfectly correct password;
262     * a document written with qpdf's --cleartext-metadata is exactly that
263     * case. Absent (the overwhelmingly common case) means true.
264     *
265     * @param  array<string, string|int|bool> $encryptDict must contain O, U, P (as built by buildRevision4()),
266     *                                                     and may carry EncryptMetadata
267     * @param  string                         $fileId raw bytes of the PDF's first /ID element
268     * @param  string                         $candidatePassword
269     * @throws Exception
270     * @return string 16 raw bytes
271     */
272    public static function openRevision4(array $encryptDict, string $fileId, string $candidatePassword): string
273    {
274        $o = (string)($encryptDict['O'] ?? '');
275        $u = (string)($encryptDict['U'] ?? '');
276
277        if ((strlen($o) != 32) || (strlen($u) != 32) || !isset($encryptDict['P'])) {
278            throw new Exception(
279                'Error: The encryption dictionary is malformed - /O and /U must be 32 bytes and /P must be present.'
280            );
281        }
282
283        $p               = (int)$encryptDict['P'];
284        $expected        = substr($u, 0, 16);
285        $encryptMetadata = (($encryptDict['EncryptMetadata'] ?? true) !== false);
286
287        // Algorithm 6 - the candidate treated as the user password.
288        $fileKey = self::deriveRevision4FileKeyFromUserPassword(
289            $candidatePassword, $o, $p, $fileId, $encryptMetadata
290        );
291        if (hash_equals($expected, substr(self::computeURevision4($fileKey, $fileId), 0, 16))) {
292            return $fileKey;
293        }
294
295        // Algorithm 7 - the candidate treated as the owner password. Peel the
296        // padded user password out of /O, then re-run the user path on it.
297        $fileKey = self::deriveRevision4FileKey(
298            self::recoverPaddedUserPassword($candidatePassword, $o), $o, $p, $fileId, 16, $encryptMetadata
299        );
300        if (hash_equals($expected, substr(self::computeURevision4($fileKey, $fileId), 0, 16))) {
301            return $fileKey;
302        }
303
304        throw new Exception('Error: The password provided is incorrect for this encrypted PDF.');
305    }
306
307    /**
308     * ISO 32000-1 Annex C, Algorithm 7 - recover the padded USER password out
309     * of the stored /O value given a candidate owner password, by undoing
310     * Algorithm 3's chained RC4 passes.
311     *
312     * Steps (a)-(b) rebuild Algorithm 3's RC4 key from the owner password by
313     * exactly the same route computeORevision4() takes - note the 50-round
314     * loop re-hashes the FULL digest here, unlike Algorithm 2's, which
315     * truncates to the key length first. Step (c) then runs the 19 XOR'd-key
316     * rounds with the counter descending 19 to 1, followed by the unmodified
317     * key, which is how the spec words the reversal.
318     *
319     * That descending order is written out literally to match the spec, but
320     * it is not actually load-bearing, and it is worth recording why so nobody
321     * "fixes" it later. Rc4::crypt() rebuilds its state array from scratch on
322     * every call and its keystream is a pure function of (key, byte position)
323     * - nothing carries over between calls - so over these fixed 32 bytes
324     * Algorithm 3 collapses to
325     *
326     *     O = padded_user_password XOR ks(K) XOR ks(K^1) XOR ... XOR ks(K^19)
327     *
328     * and re-applying the same 20 keys to /O cancels every term, in whatever
329     * order. What IS load-bearing is the SET of round keys - all 20 of them,
330     * no more and no fewer - and that each round consumes the previous
331     * round's output so those keystreams accumulate at all.
332     *
333     * A wrong owner password does not fail here; it simply returns 32
334     * meaningless bytes. Deciding correctness is openRevision4()'s job, via
335     * the /U comparison.
336     *
337     * @param  string $ownerCandidate
338     * @param  string $oValue 32 raw bytes
339     * @param  int    $keyLength RC4 key length in bytes
340     * @return string exactly 32 bytes, the padded user password
341     */
342    protected static function recoverPaddedUserPassword(
343        string $ownerCandidate, string $oValue, int $keyLength = 16
344    ): string
345    {
346        $digest = md5(self::padPassword($ownerCandidate), true);
347        for ($i = 0; $i < self::KEY_ROUNDS; $i++) {
348            $digest = md5($digest, true);
349        }
350        $rc4Key = substr($digest, 0, $keyLength);
351
352        $decrypted = $oValue;
353        for ($round = self::RC4_ROUNDS; $round >= 1; $round--) {
354            $decrypted = Rc4::crypt(self::xorKey($rc4Key, $round), $decrypted);
355        }
356
357        return Rc4::crypt($rc4Key, $decrypted);
358    }
359
360    /**
361     * The public entry point for the read path (and this plan's own
362     * round-trip test): recover the file key from a candidate user
363     * password given the already-computed /O, /P, and file /ID.
364     *
365     * ISO 32000-1 Annex C, Algorithm 2. Note this always returns a key -
366     * a wrong password yields a wrong key rather than an error. Deciding
367     * whether the password was correct is Algorithm 6's job: re-run
368     * Algorithm 5 with the recovered key and compare against /U.
369     *
370     * @param  string $userPassword
371     * @param  string $oValue 32 raw bytes
372     * @param  int    $p
373     * @param  string $fileId raw bytes of the PDF's first /ID element
374     * @param  bool   $encryptMetadata the document's /EncryptMetadata (Algorithm 2 step (f))
375     * @return string 16 raw bytes
376     */
377    public static function deriveRevision4FileKeyFromUserPassword(
378        string $userPassword, string $oValue, int $p, string $fileId, bool $encryptMetadata = true
379    ): string
380    {
381        return self::deriveRevision4FileKey(
382            self::padPassword($userPassword), $oValue, $p, $fileId, 16, $encryptMetadata
383        );
384    }
385
386    /**
387     * ISO 32000-1 Annex C, Algorithm 2 - the File Encryption Key itself.
388     *
389     * The MD5 is fed, in this exact order, the padded user password, the
390     * whole 32-byte /O string, /P as four bytes low-order first (it is a
391     * signed 32-bit value, so it is masked to unsigned before packing), and
392     * the first element of the document /ID. Every one of those is public
393     * except the password, which is what makes the key password-derived.
394     *
395     * The 50-round loop then re-hashes only the first $keyLength bytes of
396     * each digest. That truncation is deliberate and is the one thing
397     * separating this loop from the superficially identical one in
398     * Algorithm 3, which re-hashes the full 16-byte digest. For a 128-bit
399     * key the two happen to coincide (16 bytes is the whole digest); for a
400     * 40-bit key they do not, so the distinction is kept explicit here.
401     *
402     * Step (f) then appends four 0xFF bytes when - and only when - the
403     * document declares /EncryptMetadata false (this is a revision >= 4 rule,
404     * and openRevision4() is revision 4 by construction). Documents this
405     * component WRITES always encrypt their metadata, so the build direction
406     * never passes anything but the default here; documents it READS may not,
407     * and skipping the step for one of those derives a wrong key that then
408     * misreports a correct password as incorrect.
409     *
410     * @param  string $paddedUserPassword exactly 32 bytes
411     * @param  string $oValue 32 raw bytes
412     * @param  int    $p
413     * @param  string $fileId raw bytes of the PDF's first /ID element
414     * @param  int    $keyLength file key length in bytes
415     * @param  bool   $encryptMetadata the document's /EncryptMetadata
416     * @return string $keyLength raw bytes
417     */
418    protected static function deriveRevision4FileKey(
419        string $paddedUserPassword, string $oValue, int $p, string $fileId, int $keyLength = 16,
420        bool $encryptMetadata = true
421    ): string
422    {
423        $hash = hash_init('md5');
424        hash_update($hash, $paddedUserPassword);
425        hash_update($hash, $oValue);
426        hash_update($hash, pack('V', $p & 0xFFFFFFFF));
427        hash_update($hash, $fileId);
428
429        if (!$encryptMetadata) {
430            hash_update($hash, "\xFF\xFF\xFF\xFF");
431        }
432
433        $digest = hash_final($hash, true);
434
435        for ($i = 0; $i < self::KEY_ROUNDS; $i++) {
436            $digest = md5(substr($digest, 0, $keyLength), true);
437        }
438
439        return substr($digest, 0, $keyLength);
440    }
441
442    /**
443     * ISO 32000-1 Annex C, Algorithm 2 step (a): pad the password out to
444     * exactly 32 bytes with the leading bytes of the fixed padding string,
445     * or truncate it to 32 bytes if it is longer.
446     *
447     * @param  string $password
448     * @return string exactly 32 bytes
449     */
450    protected static function padPassword(string $password): string
451    {
452        $password = substr($password, 0, 32);
453
454        return $password . substr(self::PADDING, 0, 32 - strlen($password));
455    }
456
457    /**
458     * ISO 32000-1 Annex C, Algorithm 3 - the /O entry.
459     *
460     * Steps (a)-(d) turn the owner password into an RC4 key: pad it, MD5 it,
461     * then re-hash the digest 50 more times (revision 3+ only). Unlike
462     * Algorithm 2's loop this one feeds the FULL previous digest back in,
463     * not a truncated copy.
464     *
465     * Steps (e)-(g) then encrypt the padded USER password with that key,
466     * and re-encrypt the result 19 more times with the key XOR'd against
467     * the round counter, 1 through 19, each round applied to the previous
468     * round's ciphertext. A reader holding the owner password undoes this
469     * by running the chain backwards - key XOR 19 first, then 18, down to
470     * 1, then the unmodified key - and recovers the padded user password
471     * (Algorithm 7), from which it proceeds down the ordinary user path.
472     *
473     * Worth recording, since it is easy to assume otherwise: with RC4 the
474     * ORDER of these rounds has no effect on the result. RC4's keystream
475     * depends only on the key and the requested length, and every round
476     * here processes the same 32 bytes, so the chain collapses to
477     * padded_password XOR ks(K) XOR ks(K^1) XOR ... XOR ks(K^19), and XOR
478     * is commutative. What is load-bearing is the SET of round keys - all
479     * 20 of them, no more and no fewer - and the chaining, which is what
480     * makes those keystreams accumulate at all. An implementation that fed
481     * every round the original first-pass ciphertext instead of the running
482     * one would emit 32 equally plausible bytes that no reader could open.
483     *
484     * @param  string $ownerPassword the effective owner password
485     * @param  string $userPassword
486     * @param  int    $keyLength RC4 key length in bytes
487     * @return string exactly 32 bytes
488     */
489    protected static function computeORevision4(
490        string $ownerPassword, string $userPassword, int $keyLength = 16
491    ): string
492    {
493        $digest = md5(self::padPassword($ownerPassword), true);
494        for ($i = 0; $i < self::KEY_ROUNDS; $i++) {
495            $digest = md5($digest, true);
496        }
497        $rc4Key = substr($digest, 0, $keyLength);
498
499        $encrypted = Rc4::crypt($rc4Key, self::padPassword($userPassword));
500        for ($round = 1; $round <= self::RC4_ROUNDS; $round++) {
501            $encrypted = Rc4::crypt(self::xorKey($rc4Key, $round), $encrypted);
502        }
503
504        return $encrypted;
505    }
506
507    /**
508     * ISO 32000-1 Annex C, Algorithm 5 - the /U entry for revision 3+.
509     *
510     * /U is a checksum on the file key: MD5 of the padding string followed
511     * by the document /ID, encrypted with the file key and put through the
512     * same 19 chained XOR'd-key rounds as Algorithm 3. Note the hash input
513     * is the bare padding string, NOT the padded user password - the
514     * password's contribution arrives only through the file key.
515     *
516     * A reader validates a user password by deriving a candidate file key
517     * (Algorithm 2), re-running this, and comparing the first 16 bytes
518     * against /U. The trailing 16 bytes are arbitrary per the spec, purely
519     * to bring the entry to the fixed 32-byte width; zeros are used here.
520     *
521     * @param  string $fileKey 16 raw bytes
522     * @param  string $fileId raw bytes of the PDF's first /ID element
523     * @return string exactly 32 bytes
524     */
525    protected static function computeURevision4(string $fileKey, string $fileId): string
526    {
527        $encrypted = Rc4::crypt($fileKey, md5(self::PADDING . $fileId, true));
528        for ($round = 1; $round <= self::RC4_ROUNDS; $round++) {
529            $encrypted = Rc4::crypt(self::xorKey($fileKey, $round), $encrypted);
530        }
531
532        return $encrypted . str_repeat("\x00", 16);
533    }
534
535    /**
536     * XOR every byte of an RC4 key against a single-byte round counter, per
537     * step (g) of Algorithm 3 and step (e) of Algorithm 5.
538     *
539     * @param  string $key
540     * @param  int    $round 1-19
541     * @return string same length as $key
542     */
543    protected static function xorKey(string $key, int $round): string
544    {
545        $result = '';
546        for ($i = 0, $len = strlen($key); $i < $len; $i++) {
547            $result .= chr(ord($key[$i]) ^ $round);
548        }
549
550        return $result;
551    }
552
553    /**
554     * ISO 32000-2 Annex C, Algorithm 2.B - the "hardened hash". Produces a
555     * 32-byte hash from a password, an 8-byte salt, and (for the owner
556     * password only) the 48-byte /U string computed just before it.
557     *
558     * The loop runs a minimum of 64 rounds and then keeps going until the
559     * last byte of the round's AES output is less than or equal to
560     * (round number - 32), with rounds counted from 1. Because that last
561     * byte is effectively uniform over 0-255, the extra rounds beyond 64
562     * are data-dependent and unpredictable, which is the entire point: the
563     * work factor cannot be short-circuited by an attacker. The condition
564     * is guaranteed to terminate, since by round 288 the threshold reaches
565     * 255 and every possible byte value satisfies it.
566     *
567     * Public because the read/verify direction (Algorithms 11 and 12) needs
568     * the identical primitive to check a supplied password against /U or /O.
569     *
570     * @param  string $password already prepared/truncated password bytes
571     * @param  string $salt     8 raw bytes
572     * @param  string $uData    the 48-byte /U string, or '' for user-password hashes
573     * @throws Exception
574     * @return string 32 raw bytes
575     */
576    public static function hash2B(string $password, string $salt, string $uData): string
577    {
578        $k     = hash('sha256', $password . $salt . $uData, true);
579        $round = 0;
580
581        while (true) {
582            $round++;
583
584            // (a) 64 repetitions of password + K + udata. K grows to 48 or
585            // 64 bytes after the first round; the whole of it is repeated.
586            $k1 = str_repeat($password . $k . $uData, 64);
587
588            // (b) AES-128-CBC, no padding, key = K[0..15], IV = K[16..31].
589            $e = openssl_encrypt(
590                $k1, 'aes-128-cbc', substr($k, 0, 16), OPENSSL_RAW_DATA | OPENSSL_ZERO_PADDING, substr($k, 16, 16)
591            );
592            if ($e === false) {
593                throw new Exception('Hardened hash AES-128 round encryption failed');
594            }
595
596            // (c) The first 16 bytes of E as a big-endian integer, modulo 3.
597            // 256 % 3 == 1, so every byte's positional weight is 1 mod 3 and
598            // the sum of the bytes is congruent to the full integer mod 3.
599            $sum = 0;
600            for ($i = 0; $i < 16; $i++) {
601                $sum += ord($e[$i]);
602            }
603
604            // (d) Re-hash E with the selected digest; K becomes 32/48/64 bytes.
605            $k = hash(match ($sum % 3) {
606                1       => 'sha384',
607                2       => 'sha512',
608                default => 'sha256',
609            }, $e, true);
610
611            if (($round >= 64) && (ord($e[strlen($e) - 1]) <= ($round - 32))) {
612                break;
613            }
614        }
615
616        return substr($k, 0, 32);
617    }
618
619    /**
620     * AES-256-CBC, no padding, zero IV - used only to wrap the File
621     * Encryption Key inside /UE and /OE (ISO 32000-2 Algorithms 8 and 9,
622     * step (b)), never for actual object content.
623     *
624     * The all-zero IV is mandated by the spec and is safe here specifically
625     * because the wrapping key is itself derived from a fresh random 8-byte
626     * key salt on every build: no two documents ever wrap under the same
627     * key, so the IV-reuse weakness that a fixed IV would normally create
628     * does not arise.
629     *
630     * @param  string $key  32 raw bytes, the hash of password + key salt
631     * @param  string $data 32 raw bytes, the file encryption key
632     * @throws Exception
633     * @return string 32 raw bytes
634     */
635    protected static function wrapFileKey(string $key, string $data): string
636    {
637        $wrapped = openssl_encrypt(
638            $data, 'aes-256-cbc', $key, OPENSSL_RAW_DATA | OPENSSL_ZERO_PADDING, str_repeat("\x00", 16)
639        );
640        if ($wrapped === false) {
641            throw new Exception('File encryption key wrapping failed');
642        }
643
644        return $wrapped;
645    }
646
647    /**
648     * The inverse of wrapFileKey(): AES-256-CBC, no padding, zero IV, per
649     * ISO 32000-2 Algorithm 2.A steps (f) and (g). Unwraps the File
650     * Encryption Key out of /UE or /OE.
651     *
652     * This step cannot itself tell a right key from a wrong one - AES-CBC
653     * with no padding decrypts any 32 bytes to some other 32 bytes - which
654     * is precisely why the validation-salt hash comparison has to happen
655     * first. /Perms (Algorithm 13) is what independently confirms the
656     * recovered key afterwards, should a caller want that.
657     *
658     * @param  string $key  32 raw bytes, the hash of password + key salt
659     * @param  string $data 32 raw bytes, the wrapped file key from /UE or /OE
660     * @throws Exception
661     * @return string 32 raw bytes
662     */
663    protected static function unwrapFileKey(string $key, string $data): string
664    {
665        $fileKey = openssl_decrypt(
666            $data, 'aes-256-cbc', $key, OPENSSL_RAW_DATA | OPENSSL_ZERO_PADDING, str_repeat("\x00", 16)
667        );
668        if ($fileKey === false) {
669            throw new Exception('File encryption key unwrapping failed');
670        }
671
672        return $fileKey;
673    }
674
675    /**
676     * ISO 32000-2 Annex C, Algorithm 10 - an encrypted copy of the
677     * permission bits, letting a conforming reader detect a tampered /P
678     * value even if it doesn't cross-check against /O or /U.
679     *
680     * The 16-byte cleartext block is: /P extended to 64 bits by setting the
681     * high 32 bits to 1, stored low-order byte first (bytes 0-7); the
682     * EncryptMetadata flag as 'T' or 'F' (byte 8); the literal marker "adb"
683     * (bytes 9-11); and 4 random bytes (bytes 12-15).
684     *
685     * @param  string $fileKey 32 raw bytes
686     * @param  int    $p
687     * @throws Exception
688     * @return string 16 raw bytes
689     */
690    protected static function computePerms(string $fileKey, int $p): string
691    {
692        $block  = pack('V', $p & 0xFFFFFFFF);
693        $block .= "\xFF\xFF\xFF\xFF";
694        $block .= 'T'; // EncryptMetadata always true - see spec Non-goals
695        $block .= 'adb';
696        $block .= random_bytes(4);
697
698        $perms = openssl_encrypt($block, 'aes-256-ecb', $fileKey, OPENSSL_RAW_DATA | OPENSSL_ZERO_PADDING);
699        if ($perms === false) {
700            throw new Exception('Permissions block encryption failed');
701        }
702
703        return $perms;
704    }
705
706    /**
707     * ISO 32000-2, 7.6.4.3.3: the password is a UTF-8 byte string truncated
708     * to 127 bytes.
709     *
710     * The spec also calls for SASLprep (RFC 4013) normalization ahead of the
711     * truncation. That is not applied here - it only affects passwords
712     * containing non-ASCII characters, and getting it wrong would be worse
713     * than not doing it, since a reader that also skips SASLprep (as several
714     * do) would then disagree with us on every such password.
715     *
716     * @param  string $password
717     * @return string
718     */
719    protected static function preparePassword(string $password): string
720    {
721        return substr($password, 0, self::MAX_PASSWORD_LENGTH);
722    }
723
724}