Code Coverage |
||||||||||
Lines |
Functions and Methods |
Classes and Traits |
||||||||
| Total | |
85.53% |
65 / 76 |
|
20.00% |
1 / 5 |
CRAP | |
0.00% |
0 / 1 |
| Pkcs7Signer | |
85.53% |
65 / 76 |
|
20.00% |
1 / 5 |
21.21 | |
0.00% |
0 / 1 |
| __construct | |
100.00% |
3 / 3 |
|
100.00% |
1 / 1 |
1 | |||
| sign | |
86.49% |
32 / 37 |
|
0.00% |
0 / 1 |
9.20 | |||
| extractDerFromSmime | |
81.82% |
9 / 11 |
|
0.00% |
0 / 1 |
4.10 | |||
| createSelfSignedTestCredentials | |
85.71% |
18 / 21 |
|
0.00% |
0 / 1 |
4.05 | |||
| certificateToPem | |
75.00% |
3 / 4 |
|
0.00% |
0 / 1 |
2.06 | |||
| 1 | <?php |
| 2 | |
| 3 | declare(strict_types=1); |
| 4 | |
| 5 | namespace Phpdftk\Pdf\Core\Interactive\Signature; |
| 6 | |
| 7 | /** |
| 8 | * PKCS#7 / CMS detached signer — ISO 32000-2 §12.8.3.3. |
| 9 | * |
| 10 | * Thin wrapper over PHP's `openssl_pkcs7_sign()` that produces the raw |
| 11 | * DER-encoded PKCS#7 SignedData bytes used as the /Contents value of a |
| 12 | * PDF {@see SignatureValue}. The signature is detached: the signed data |
| 13 | * is the concatenation of the two byte ranges around the /Contents |
| 14 | * placeholder in the serialized PDF. |
| 15 | * |
| 16 | * PHP's extension writes an SMIME multipart envelope to a file; we parse |
| 17 | * it back out, extract the base64-encoded signature attachment, and |
| 18 | * decode it to raw DER. This is how `TCPDF`, `setasign/SetaPDF-Signer`, |
| 19 | * and similar libraries interoperate with openssl. |
| 20 | * |
| 21 | * Extra certificates (chain) and flags are passthroughs. |
| 22 | */ |
| 23 | final class Pkcs7Signer |
| 24 | { |
| 25 | /** @var \OpenSSLCertificate|string */ |
| 26 | private $certificate; |
| 27 | |
| 28 | /** @var \OpenSSLAsymmetricKey|array{0: \OpenSSLCertificate|string, 1: string}|string */ |
| 29 | private $privateKey; |
| 30 | |
| 31 | /** @var list<\OpenSSLCertificate|string> */ |
| 32 | private array $extraCerts; |
| 33 | |
| 34 | /** |
| 35 | * @param \OpenSSLCertificate|string $certificate PEM cert or resource |
| 36 | * @param \OpenSSLAsymmetricKey|array{0: \OpenSSLCertificate|string, 1: string}|string $privateKey PEM key, key+pass pair, or resource |
| 37 | * @param list<\OpenSSLCertificate|string> $extraCerts additional certs to include in the chain |
| 38 | */ |
| 39 | public function __construct( |
| 40 | $certificate, |
| 41 | $privateKey, |
| 42 | array $extraCerts = [], |
| 43 | ) { |
| 44 | $this->certificate = $certificate; |
| 45 | $this->privateKey = $privateKey; |
| 46 | $this->extraCerts = $extraCerts; |
| 47 | } |
| 48 | |
| 49 | /** |
| 50 | * Sign `$data` and return raw DER PKCS#7 bytes suitable for the |
| 51 | * /Contents entry of a signature value dictionary. |
| 52 | */ |
| 53 | public function sign(string $data): string |
| 54 | { |
| 55 | $tmp = tempnam(sys_get_temp_dir(), 'phpdftk_sig_'); |
| 56 | if ($tmp === false) { |
| 57 | throw new \RuntimeException('Unable to create temp file for signing'); |
| 58 | } |
| 59 | $in = $tmp . '.in'; |
| 60 | $out = $tmp . '.out'; |
| 61 | |
| 62 | try { |
| 63 | file_put_contents($in, $data); |
| 64 | |
| 65 | $extraCertsFile = null; |
| 66 | if ($this->extraCerts !== []) { |
| 67 | $extraCertsFile = $tmp . '.chain'; |
| 68 | $chain = ''; |
| 69 | foreach ($this->extraCerts as $cert) { |
| 70 | $chain .= is_string($cert) |
| 71 | ? $cert |
| 72 | : self::certificateToPem($cert); |
| 73 | } |
| 74 | file_put_contents($extraCertsFile, $chain); |
| 75 | } |
| 76 | |
| 77 | $ok = openssl_pkcs7_sign( |
| 78 | $in, |
| 79 | $out, |
| 80 | $this->certificate, |
| 81 | $this->privateKey, |
| 82 | [], |
| 83 | PKCS7_BINARY | PKCS7_DETACHED, |
| 84 | $extraCertsFile, |
| 85 | ); |
| 86 | if ($ok !== true) { |
| 87 | throw new \RuntimeException( |
| 88 | 'openssl_pkcs7_sign failed: ' . (openssl_error_string() ?: 'unknown error'), |
| 89 | ); |
| 90 | } |
| 91 | |
| 92 | $smime = file_get_contents($out); |
| 93 | if ($smime === false) { |
| 94 | throw new \RuntimeException('Failed to read signed output'); |
| 95 | } |
| 96 | |
| 97 | return self::extractDerFromSmime($smime); |
| 98 | } finally { |
| 99 | @unlink($tmp); |
| 100 | @unlink($in); |
| 101 | @unlink($out); |
| 102 | if (isset($extraCertsFile)) { |
| 103 | @unlink($extraCertsFile); |
| 104 | } |
| 105 | } |
| 106 | } |
| 107 | |
| 108 | /** |
| 109 | * Parse the base64 PKCS#7 attachment out of an SMIME multipart body |
| 110 | * and return the decoded DER bytes. |
| 111 | */ |
| 112 | public static function extractDerFromSmime(string $smime): string |
| 113 | { |
| 114 | // Find the signature attachment part. |
| 115 | if (!preg_match( |
| 116 | '/Content-Type:\s*application\/(?:x-)?pkcs7-signature[^\r\n]*\r?\n(?:[^\r\n]+\r?\n)*\r?\n(.+?)\r?\n--/s', |
| 117 | $smime, |
| 118 | $m, |
| 119 | )) { |
| 120 | throw new \RuntimeException('Could not locate pkcs7-signature part in SMIME output'); |
| 121 | } |
| 122 | |
| 123 | $base64 = preg_replace('/\s+/', '', $m[1]) ?? ''; |
| 124 | $der = base64_decode($base64, true); |
| 125 | if ($der === false || $der === '') { |
| 126 | throw new \RuntimeException('Failed to base64-decode pkcs7-signature'); |
| 127 | } |
| 128 | return $der; |
| 129 | } |
| 130 | |
| 131 | /** |
| 132 | * Convenience: generate a throwaway self-signed cert + key pair for |
| 133 | * tests and local demos. Not for production use. |
| 134 | * |
| 135 | * @return array{cert: string, key: string} |
| 136 | */ |
| 137 | public static function createSelfSignedTestCredentials( |
| 138 | string $commonName = 'phpdftk test', |
| 139 | int $days = 365, |
| 140 | ): array { |
| 141 | $config = [ |
| 142 | 'digest_alg' => 'sha256', |
| 143 | 'private_key_bits' => 2048, |
| 144 | 'private_key_type' => OPENSSL_KEYTYPE_RSA, |
| 145 | ]; |
| 146 | $keyRes = openssl_pkey_new($config); |
| 147 | if ($keyRes === false) { |
| 148 | throw new \RuntimeException('openssl_pkey_new failed'); |
| 149 | } |
| 150 | |
| 151 | $csr = openssl_csr_new( |
| 152 | ['commonName' => $commonName, 'organizationName' => 'phpdftk'], |
| 153 | $keyRes, |
| 154 | $config, |
| 155 | ); |
| 156 | if ($csr === false) { |
| 157 | throw new \RuntimeException('openssl_csr_new failed'); |
| 158 | } |
| 159 | |
| 160 | $certRes = openssl_csr_sign($csr, null, $keyRes, $days, $config); |
| 161 | if ($certRes === false) { |
| 162 | throw new \RuntimeException('openssl_csr_sign failed'); |
| 163 | } |
| 164 | |
| 165 | openssl_x509_export($certRes, $certPem); |
| 166 | openssl_pkey_export($keyRes, $keyPem); |
| 167 | |
| 168 | return ['cert' => $certPem, 'key' => $keyPem]; |
| 169 | } |
| 170 | |
| 171 | /** @param \OpenSSLCertificate|string $cert */ |
| 172 | private static function certificateToPem($cert): string |
| 173 | { |
| 174 | if (is_string($cert)) { |
| 175 | return $cert; |
| 176 | } |
| 177 | openssl_x509_export($cert, $pem); |
| 178 | return $pem; |
| 179 | } |
| 180 | } |