1<?php declare(strict_types=1); 2 3namespace PrinsFrank\PdfParser\Document\Security; 4 5use PrinsFrank\PdfParser\Document\Encryption\RC4; 6use PrinsFrank\PdfParser\Document\Object\Decorator\EncryptDictionary; 7use PrinsFrank\PdfParser\Exception\NotImplementedException; 8use PrinsFrank\PdfParser\Exception\ParseFailureException; 9use SensitiveParameter; 10 11readonly class StandardSecurity { 12 /** @see 7.6.4.3.2 a */ 13 public const PADDING_STRING = "\x28\xBF\x4E\x5E\x4E\x75\x8A\x41\x64\x00\x4E\x56\xFF\xFA\x01\x08\x2E\x2E\x00\xB6\xD0\x68\x3E\x80\x2F\x0C\xA9\xFE\x64\x53\x69\x7A"; 14 private const PASSWORD_LENGTH = 32; 15 16 public function __construct( 17 #[SensitiveParameter] 18 public ?string $userPassword = null, 19 #[SensitiveParameter] 20 public ?string $ownerPassword = null, 21 ) {} 22 23 /** @throws ParseFailureException|NotImplementedException */ 24 public function getFileEncryptionKey(EncryptDictionary $encryptDictionary, string $firstID): ?FileEncryptionKey { 25 if ($this->ownerPassword !== null) { 26 $recoveredPaddedUserPassword = $this->recoverPaddedUserPasswordFromOwnerEntry($encryptDictionary); 27 $fileEncryptionKey = $this->getFileEncryptionKeyForUserPassword($recoveredPaddedUserPassword, $encryptDictionary, $firstID); 28 if ($this->isFileEncryptionKeyValid($fileEncryptionKey, $encryptDictionary, $firstID)) { 29 return $fileEncryptionKey; 30 } 31 } 32 33 $fileEncryptionKey = $this->getFileEncryptionKeyForUserPassword($this->getPaddedUserPassword(), $encryptDictionary, $firstID); 34 if ($this->isFileEncryptionKeyValid($fileEncryptionKey, $encryptDictionary, $firstID)) { 35 return $fileEncryptionKey; 36 } 37 38 return null; 39 } 40 41 /** @see 7.6.4.4.3, 7.6.4.4.4 and 7.6.4.4.5 */ 42 private function isFileEncryptionKeyValid(FileEncryptionKey $fileEncryptionKey, EncryptDictionary $encryptDictionary, string $firstID): bool { 43 $userPasswordEntry = $encryptDictionary->getUserPasswordEntry(); 44 $securityHandlerRevision = $encryptDictionary->getStandardSecurityHandlerRevision(); 45 if ($securityHandlerRevision === StandardSecurityHandlerRevision::v2) { // @see 7.6.4.4.3, step b 46 return hash_equals($userPasswordEntry, RC4::crypt($fileEncryptionKey->value, self::PADDING_STRING)); 47 } 48 49 if (in_array($securityHandlerRevision, [StandardSecurityHandlerRevision::v3, StandardSecurityHandlerRevision::v4], true)) { // @see 7.6.4.4.4, step b through e 50 $hash = md5(self::PADDING_STRING . $firstID, true); 51 $encryptedHash = RC4::crypt($fileEncryptionKey->value, $hash); 52 for ($i = 1; $i <= 19; $i++) { 53 $encryptedHash = RC4::crypt( 54 implode('', array_map( 55 fn($c) => chr(ord($c) ^ $i), 56 str_split($fileEncryptionKey->value), 57 )), 58 $encryptedHash, 59 ); 60 } 61 62 return hash_equals(substr($userPasswordEntry, 0, 16), $encryptedHash); 63 } 64 65 throw new NotImplementedException('Unsupported security handler revision: ' . $securityHandlerRevision->value); 66 } 67 68 /** @see 7.6.4.4.6 */ 69 private function recoverPaddedUserPasswordFromOwnerEntry(EncryptDictionary $encryptDictionary): string { 70 if (in_array($encryptDictionary->getStandardSecurityHandlerRevision(), [StandardSecurityHandlerRevision::v2, StandardSecurityHandlerRevision::v3, StandardSecurityHandlerRevision::v4], true) === false) { 71 throw new NotImplementedException('Unsupported security handler revision: ' . $encryptDictionary->getStandardSecurityHandlerRevision()->value); 72 } 73 74 $fileEncryptionKeyLengthInBits = $encryptDictionary->getLengthFileEncryptionKeyInBits() ?? throw new ParseFailureException(); 75 if ($encryptDictionary->getSecurityAlgorithm() === SecurityAlgorithm::AES_Key_length_256) { // V = 4 76 throw new NotImplementedException('AES-based stream decryption is not yet supported.'); 77 } 78 79 if ($fileEncryptionKeyLengthInBits % 8 !== 0 || !is_int($fileEncryptionKeyLengthInBytes = $fileEncryptionKeyLengthInBits / 8)) { 80 throw new ParseFailureException('Unsupported file encryption key length in bits: ' . $fileEncryptionKeyLengthInBits); 81 } 82 83 $md5Hash = md5($this->getPaddedOwnerPassword(), true); 84 if ($encryptDictionary->getStandardSecurityHandlerRevision() !== StandardSecurityHandlerRevision::v2) { 85 for ($i = 1; $i <= 50; $i++) { // step c 86 $md5Hash = md5($md5Hash, true); 87 } 88 } 89 90 if ($encryptDictionary->getStandardSecurityHandlerRevision() === StandardSecurityHandlerRevision::v2) { 91 $ownerPasswordDecryptionKey = substr($md5Hash, 0, 5); 92 } else { 93 $ownerPasswordDecryptionKey = substr($md5Hash, 0, $fileEncryptionKeyLengthInBytes); 94 } 95 96 $ownerPasswordEntry = $encryptDictionary->getOwnerPasswordEntry(); 97 if ($encryptDictionary->getStandardSecurityHandlerRevision() === StandardSecurityHandlerRevision::v2) { 98 $paddedUserPassword = RC4::crypt($ownerPasswordDecryptionKey, $ownerPasswordEntry); 99 } else { 100 $paddedUserPassword = $ownerPasswordEntry; 101 for ($i = 19; $i >= 0; $i--) { 102 $paddedUserPassword = RC4::crypt( 103 implode('', array_map( 104 fn($c) => chr(ord($c) ^ $i), 105 str_split($ownerPasswordDecryptionKey), 106 )), 107 $paddedUserPassword, 108 ); 109 } 110 } 111 112 return $paddedUserPassword; 113 } 114 115 /** @see 7.6.4.3.2 */ 116 private function getFileEncryptionKeyForUserPassword(string $paddedUserPassword, EncryptDictionary $encryptDictionary, string $firstIDValue): FileEncryptionKey { 117 if (in_array($encryptDictionary->getStandardSecurityHandlerRevision(), [StandardSecurityHandlerRevision::v2, StandardSecurityHandlerRevision::v3, StandardSecurityHandlerRevision::v4], true) === false) { 118 throw new NotImplementedException('Unsupported security handler revision: ' . $encryptDictionary->getStandardSecurityHandlerRevision()->value); 119 } 120 121 $fileEncryptionKeyLengthInBits = $encryptDictionary->getLengthFileEncryptionKeyInBits() ?? throw new ParseFailureException(); 122 if ($encryptDictionary->getSecurityAlgorithm() === SecurityAlgorithm::AES_Key_length_256) { // V = 4 123 throw new NotImplementedException('AES-based stream decryption is not yet supported.'); 124 } 125 126 if ($fileEncryptionKeyLengthInBits % 8 !== 0 || !is_int($fileEncryptionKeyLengthInBytes = $fileEncryptionKeyLengthInBits / 8)) { 127 throw new ParseFailureException('Unsupported file encryption key length in bits: ' . $fileEncryptionKeyLengthInBits); 128 } 129 130 $hashedString 131 = $paddedUserPassword // step a+b 132 . $encryptDictionary->getOwnerPasswordEntry() // step c 133 . pack('V', $encryptDictionary->getPValue()) // step d 134 . $firstIDValue; // step e 135 if ($encryptDictionary->getStandardSecurityHandlerRevision()->value >= 4 && $encryptDictionary->isMetadataEncrypted() === false) { 136 $hashedString .= "\xFF\xFF\xFF\xFF"; 137 } 138 139 $md5Hash = md5($hashedString, true); 140 if ($encryptDictionary->getStandardSecurityHandlerRevision() === StandardSecurityHandlerRevision::v2) { 141 return new FileEncryptionKey(substr($md5Hash, 0, 5)); 142 } 143 144 for ($i = 1; $i <= 50; $i++) { // step h 145 $md5Hash = md5(substr($md5Hash, 0, $fileEncryptionKeyLengthInBytes), true); 146 } 147 148 return new FileEncryptionKey(substr($md5Hash, 0, $fileEncryptionKeyLengthInBytes)); 149 } 150 151 /** @see 7.6.4.3.2 step a */ 152 private function getPaddedUserPassword(): string { 153 return substr($this->userPassword ?? '', 0, self::PASSWORD_LENGTH) 154 . substr(self::PADDING_STRING, 0, max(0, self::PASSWORD_LENGTH - strlen($this->userPassword ?? ''))); 155 } 156 157 /** @see 7.6.4.3.2 step a */ 158 private function getPaddedOwnerPassword(): string { 159 return substr($this->ownerPassword ?? '', 0, self::PASSWORD_LENGTH) 160 . substr(self::PADDING_STRING, 0, max(0, self::PASSWORD_LENGTH - strlen($this->ownerPassword ?? ''))); 161 } 162} 163