Code Coverage
 
Lines
Functions and Methods
Classes and Traits
Total
85.53% covered (warning)
85.53%
65 / 76
20.00% covered (danger)
20.00%
1 / 5
CRAP
0.00% covered (danger)
0.00%
0 / 1
Pkcs7Signer
85.53% covered (warning)
85.53%
65 / 76
20.00% covered (danger)
20.00%
1 / 5
21.21
0.00% covered (danger)
0.00%
0 / 1
 __construct
100.00% covered (success)
100.00%
3 / 3
100.00% covered (success)
100.00%
1 / 1
1
 sign
86.49% covered (warning)
86.49%
32 / 37
0.00% covered (danger)
0.00%
0 / 1
9.20
 extractDerFromSmime
81.82% covered (warning)
81.82%
9 / 11
0.00% covered (danger)
0.00%
0 / 1
4.10
 createSelfSignedTestCredentials
85.71% covered (warning)
85.71%
18 / 21
0.00% covered (danger)
0.00%
0 / 1
4.05
 certificateToPem
75.00% covered (warning)
75.00%
3 / 4
0.00% covered (danger)
0.00%
0 / 1
2.06
1<?php
2
3declare(strict_types=1);
4
5namespace 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 */
23final 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}