Code Coverage
 
Lines
Functions and Methods
Classes and Traits
Total
90.00% covered (success)
90.00%
54 / 60
33.33% covered (danger)
33.33%
1 / 3
CRAP
0.00% covered (danger)
0.00%
0 / 1
PublicKeyEncryption
90.00% covered (success)
90.00%
54 / 60
33.33% covered (danger)
33.33%
1 / 3
17.29
0.00% covered (danger)
0.00%
0 / 1
 createEnvelope
85.71% covered (warning)
85.71%
24 / 28
0.00% covered (danger)
0.00%
0 / 1
7.14
 openEnvelope
91.30% covered (success)
91.30%
21 / 23
0.00% covered (danger)
0.00%
0 / 1
6.02
 deriveFileKey
100.00% covered (success)
100.00%
9 / 9
100.00% covered (success)
100.00%
1 / 1
4
1<?php
2
3declare(strict_types=1);
4
5namespace 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 */
17final 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}