xref: /plugin/totext/vendor/prinsfrank/pdfparser/src/Document/Security/StandardSecurity.php (revision b4ecfc82b02c9a76205381a28b47006474ccf569) !
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