Code Coverage |
||||||||||
Lines |
Functions and Methods |
Classes and Traits |
||||||||
| Total | |
90.00% |
54 / 60 |
|
33.33% |
1 / 3 |
CRAP | |
0.00% |
0 / 1 |
| PublicKeyEncryption | |
90.00% |
54 / 60 |
|
33.33% |
1 / 3 |
17.29 | |
0.00% |
0 / 1 |
| createEnvelope | |
85.71% |
24 / 28 |
|
0.00% |
0 / 1 |
7.14 | |||
| openEnvelope | |
91.30% |
21 / 23 |
|
0.00% |
0 / 1 |
6.02 | |||
| deriveFileKey | |
100.00% |
9 / 9 |
|
100.00% |
1 / 1 |
4 | |||
| 1 | <?php |
| 2 | |
| 3 | declare(strict_types=1); |
| 4 | |
| 5 | namespace Phpdftk\Crypt; |
| 6 | |
| 7 | /** |
| 8 | * Public-key (certificate-based) PDF encryption primitives — ISO 32000-2 §7.6.5. |
| 9 | * |
| 10 | * Creates and opens PKCS#7 CMS EnvelopedData objects that wrap the |
| 11 | * encryption seed for each recipient, and derives the file encryption |
| 12 | * key per the public-key security handler specification. |
| 13 | * |
| 14 | * Uses PHP 8.1+ `openssl_cms_encrypt()`/`openssl_cms_decrypt()` for |
| 15 | * reliable CMS envelope operations. |
| 16 | */ |
| 17 | final class PublicKeyEncryption |
| 18 | { |
| 19 | /** |
| 20 | * Create a PKCS#7 CMS EnvelopedData wrapping the seed + permissions |
| 21 | * for a single recipient. Returns raw DER-encoded bytes. |
| 22 | * |
| 23 | * Per ISO 32000-2 §7.6.5.3, the enveloped content is: |
| 24 | * 20-byte seed || 4-byte permissions (LE) || optional 4×0xFF |
| 25 | * |
| 26 | * @param string $seed 20-byte random seed |
| 27 | * @param int $permissions Permission bitfield for this recipient |
| 28 | * @param string $certPem Recipient's X.509 certificate in PEM format |
| 29 | * @param bool $encryptMetadata Whether document metadata is encrypted |
| 30 | */ |
| 31 | public static function createEnvelope( |
| 32 | string $seed, |
| 33 | int $permissions, |
| 34 | string $certPem, |
| 35 | bool $encryptMetadata = true, |
| 36 | ): string { |
| 37 | $content = $seed . pack('V', $permissions); |
| 38 | if (!$encryptMetadata) { |
| 39 | $content .= "\xFF\xFF\xFF\xFF"; |
| 40 | } |
| 41 | |
| 42 | $tmp = tempnam(sys_get_temp_dir(), 'phpdftk_pke_'); |
| 43 | if ($tmp === false) { |
| 44 | throw new \RuntimeException('Unable to create temp file for CMS encryption'); |
| 45 | } |
| 46 | $inFile = $tmp . '.in'; |
| 47 | $outFile = $tmp . '.out'; |
| 48 | |
| 49 | try { |
| 50 | file_put_contents($inFile, $content); |
| 51 | |
| 52 | // Clear stale OpenSSL errors |
| 53 | while (openssl_error_string() !== false) { |
| 54 | } |
| 55 | |
| 56 | $result = openssl_cms_encrypt( |
| 57 | $inFile, |
| 58 | $outFile, |
| 59 | $certPem, |
| 60 | [], |
| 61 | OPENSSL_CMS_BINARY | OPENSSL_CMS_NOINTERN, |
| 62 | OPENSSL_ENCODING_DER, |
| 63 | OPENSSL_CIPHER_AES_256_CBC, |
| 64 | ); |
| 65 | |
| 66 | if ($result !== true) { |
| 67 | throw new \RuntimeException('openssl_cms_encrypt failed'); |
| 68 | } |
| 69 | |
| 70 | $der = file_get_contents($outFile); |
| 71 | if ($der === false || $der === '') { |
| 72 | throw new \RuntimeException('Failed to read CMS encrypted output'); |
| 73 | } |
| 74 | |
| 75 | return $der; |
| 76 | } finally { |
| 77 | @unlink($tmp); |
| 78 | @unlink($inFile); |
| 79 | @unlink($outFile); |
| 80 | } |
| 81 | } |
| 82 | |
| 83 | /** |
| 84 | * Open a PKCS#7 CMS EnvelopedData to extract the 20-byte seed. |
| 85 | * |
| 86 | * @param string $pkcs7Der DER-encoded PKCS#7 EnvelopedData |
| 87 | * @param string $certPem Recipient's X.509 certificate in PEM format |
| 88 | * @param string $privateKeyPem Recipient's private key in PEM format |
| 89 | * @return string|null The 20-byte seed, or null if decryption fails |
| 90 | */ |
| 91 | public static function openEnvelope( |
| 92 | string $pkcs7Der, |
| 93 | string $certPem, |
| 94 | string $privateKeyPem, |
| 95 | ): ?string { |
| 96 | $tmp = tempnam(sys_get_temp_dir(), 'phpdftk_pkd_'); |
| 97 | if ($tmp === false) { |
| 98 | return null; |
| 99 | } |
| 100 | $inFile = $tmp . '.in'; |
| 101 | $outFile = $tmp . '.out'; |
| 102 | |
| 103 | try { |
| 104 | file_put_contents($inFile, $pkcs7Der); |
| 105 | |
| 106 | // Clear stale OpenSSL errors |
| 107 | while (openssl_error_string() !== false) { |
| 108 | } |
| 109 | |
| 110 | $result = openssl_cms_decrypt( |
| 111 | $inFile, |
| 112 | $outFile, |
| 113 | $certPem, |
| 114 | $privateKeyPem, |
| 115 | OPENSSL_ENCODING_DER, |
| 116 | ); |
| 117 | |
| 118 | if ($result !== true) { |
| 119 | return null; |
| 120 | } |
| 121 | |
| 122 | $content = file_get_contents($outFile); |
| 123 | if ($content === false || strlen($content) < 20) { |
| 124 | return null; |
| 125 | } |
| 126 | |
| 127 | // First 20 bytes = seed |
| 128 | return substr($content, 0, 20); |
| 129 | } finally { |
| 130 | @unlink($tmp); |
| 131 | @unlink($inFile); |
| 132 | @unlink($outFile); |
| 133 | } |
| 134 | } |
| 135 | |
| 136 | /** |
| 137 | * Derive the file encryption key per ISO 32000-2 §7.6.5.2. |
| 138 | * |
| 139 | * key = SHA-1(seed || recipient[0] || ... || recipient[n] || P_4bytes_LE || optional_0xFF×4) |
| 140 | * |
| 141 | * @param string $seed 20-byte seed |
| 142 | * @param string[] $recipientDerStrings Raw DER bytes of each PKCS#7 recipient object |
| 143 | * @param int $permissions Combined permissions (AND of all recipients) |
| 144 | * @param int $keyLengthBytes Desired key length in bytes (e.g. 16 for AES-128) |
| 145 | * @param bool $encryptMetadata Whether metadata is encrypted |
| 146 | */ |
| 147 | /** |
| 148 | * Derive the file encryption key per ISO 32000-2 §7.6.5.2. |
| 149 | * |
| 150 | * Uses SHA-1 for key lengths up to 20 bytes (AES-128), |
| 151 | * SHA-256 for longer keys (AES-256). |
| 152 | * |
| 153 | * @param string $seed 20-byte seed |
| 154 | * @param string[] $recipientDerStrings Raw DER bytes of each PKCS#7 recipient object |
| 155 | * @param int $permissions Combined permissions (AND of all recipients) |
| 156 | * @param int $keyLengthBytes Desired key length in bytes (16 for AES-128, 32 for AES-256) |
| 157 | * @param bool $encryptMetadata Whether metadata is encrypted |
| 158 | */ |
| 159 | public static function deriveFileKey( |
| 160 | string $seed, |
| 161 | array $recipientDerStrings, |
| 162 | int $permissions, |
| 163 | int $keyLengthBytes, |
| 164 | bool $encryptMetadata = true, |
| 165 | ): string { |
| 166 | $input = $seed; |
| 167 | foreach ($recipientDerStrings as $der) { |
| 168 | $input .= $der; |
| 169 | } |
| 170 | $input .= pack('V', $permissions); |
| 171 | if (!$encryptMetadata) { |
| 172 | $input .= "\xFF\xFF\xFF\xFF"; |
| 173 | } |
| 174 | |
| 175 | // SHA-1 (20 bytes max) for AES-128; SHA-256 (32 bytes) for AES-256 |
| 176 | if ($keyLengthBytes > 20) { |
| 177 | return substr(hash('sha256', $input, true), 0, $keyLengthBytes); |
| 178 | } |
| 179 | |
| 180 | return substr(sha1($input, true), 0, $keyLengthBytes); |
| 181 | } |
| 182 | } |