Code Coverage |
||||||||||
Lines |
Functions and Methods |
Classes and Traits |
||||||||
| Total | |
97.16% |
137 / 141 |
|
75.00% |
12 / 16 |
CRAP | |
0.00% |
0 / 1 |
| StandardSecurityHandler | |
97.16% |
137 / 141 |
|
75.00% |
12 / 16 |
46 | |
0.00% |
0 / 1 |
| buildRevision6 | |
100.00% |
17 / 17 |
|
100.00% |
1 / 1 |
1 | |||
| openRevision6 | |
100.00% |
14 / 14 |
|
100.00% |
1 / 1 |
7 | |||
| buildRevision4 | |
100.00% |
7 / 7 |
|
100.00% |
1 / 1 |
1 | |||
| openRevision4 | |
100.00% |
20 / 20 |
|
100.00% |
1 / 1 |
6 | |||
| recoverPaddedUserPassword | |
100.00% |
8 / 8 |
|
100.00% |
1 / 1 |
3 | |||
| deriveRevision4FileKeyFromUserPassword | |
100.00% |
3 / 3 |
|
100.00% |
1 / 1 |
1 | |||
| deriveRevision4FileKey | |
100.00% |
11 / 11 |
|
100.00% |
1 / 1 |
3 | |||
| padPassword | |
100.00% |
2 / 2 |
|
100.00% |
1 / 1 |
1 | |||
| computeORevision4 | |
100.00% |
8 / 8 |
|
100.00% |
1 / 1 |
3 | |||
| computeURevision4 | |
100.00% |
4 / 4 |
|
100.00% |
1 / 1 |
2 | |||
| xorKey | |
100.00% |
4 / 4 |
|
100.00% |
1 / 1 |
2 | |||
| hash2B | |
95.24% |
20 / 21 |
|
0.00% |
0 / 1 |
9 | |||
| wrapFileKey | |
83.33% |
5 / 6 |
|
0.00% |
0 / 1 |
2.02 | |||
| unwrapFileKey | |
83.33% |
5 / 6 |
|
0.00% |
0 / 1 |
2.02 | |||
| computePerms | |
88.89% |
8 / 9 |
|
0.00% |
0 / 1 |
2.01 | |||
| preparePassword | |
100.00% |
1 / 1 |
|
100.00% |
1 / 1 |
1 | |||
| 1 | <?php |
| 2 | declare(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 | */ |
| 15 | namespace Pop\Pdf\Build\Security; |
| 16 | |
| 17 | use 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 | */ |
| 38 | class 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 | } |