Code Coverage
 
Lines
Functions and Methods
Classes and Traits
Total
79.82% covered (warning)
79.82%
87 / 109
57.14% covered (warning)
57.14%
8 / 14
CRAP
0.00% covered (danger)
0.00%
0 / 1
OcspClient
79.82% covered (warning)
79.82%
87 / 109
57.14% covered (warning)
57.14%
8 / 14
43.50
0.00% covered (danger)
0.00%
0 / 1
 __construct
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 getOcspResponse
71.43% covered (warning)
71.43%
5 / 7
0.00% covered (danger)
0.00%
0 / 1
2.09
 buildOcspRequest
100.00% covered (success)
100.00%
16 / 16
100.00% covered (success)
100.00%
1 / 1
1
 parseOcspResponse
96.43% covered (success)
96.43%
27 / 28
0.00% covered (danger)
0.00%
0 / 1
7
 sendRequest
85.19% covered (warning)
85.19%
23 / 27
0.00% covered (danger)
0.00%
0 / 1
4.05
 assertHttpUrl
66.67% covered (warning)
66.67%
2 / 3
0.00% covered (danger)
0.00%
0 / 1
3.33
 derTlv
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 derLength
28.57% covered (danger)
28.57%
2 / 7
0.00% covered (danger)
0.00%
0 / 1
9.83
 readDerLength
35.71% covered (danger)
35.71%
5 / 14
0.00% covered (danger)
0.00%
0 / 1
15.56
 derSequence
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 derOid
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 derNull
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 derOctetString
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 derInteger
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
1<?php
2
3declare(strict_types=1);
4
5namespace Phpdftk\Pdf\Core\Interactive\Signature;
6
7/**
8 * OCSP (Online Certificate Status Protocol) client — RFC 6960.
9 *
10 * Builds OCSP requests, sends them to the responder specified in the
11 * certificate's Authority Information Access extension, and returns
12 * the raw DER-encoded OCSP response suitable for embedding in a
13 * {@see \Phpdftk\Pdf\Core\Document\DSS}.
14 *
15 * Uses inline ASN.1 DER encoding (same pattern as {@see TsaClient}).
16 */
17final class OcspClient
18{
19    /** SHA-256 OID: 2.16.840.1.101.3.4.2.1 */
20    private const OID_SHA256 = "\x60\x86\x48\x01\x65\x03\x04\x02\x01";
21
22    private int $timeout;
23
24    /**
25     * @param int $timeout HTTP request timeout in seconds
26     */
27    public function __construct(int $timeout = 30)
28    {
29        $this->timeout = $timeout;
30    }
31
32    /**
33     * Fetch an OCSP response for a certificate from its designated responder.
34     *
35     * @param string $derCert        DER-encoded certificate to check
36     * @param string $derIssuerCert  DER-encoded issuer certificate
37     * @return string Raw DER-encoded OCSPResponse
38     * @throws \RuntimeException on network error, missing OCSP URL, or responder error
39     */
40    public function getOcspResponse(string $derCert, string $derIssuerCert): string
41    {
42        $url = CertificateUtils::getOcspResponderUrl($derCert);
43        if ($url === null) {
44            throw new \RuntimeException('Certificate does not contain an OCSP responder URL (no AIA extension)');
45        }
46
47        $request = $this->buildOcspRequest($derCert, $derIssuerCert);
48        $response = $this->sendRequest($url, $request);
49        $this->parseOcspResponse($response);
50
51        return $response;
52    }
53
54    /**
55     * Build an OCSPRequest in ASN.1 DER format.
56     *
57     * OCSPRequest ::= SEQUENCE {
58     *   tbsRequest TBSRequest
59     * }
60     * TBSRequest ::= SEQUENCE {
61     *   version [0] EXPLICIT INTEGER DEFAULT v1, -- omit for v1
62     *   requestList SEQUENCE OF Request
63     * }
64     * Request ::= SEQUENCE {
65     *   reqCert CertID
66     * }
67     * CertID ::= SEQUENCE {
68     *   hashAlgorithm AlgorithmIdentifier,
69     *   issuerNameHash OCTET STRING,
70     *   issuerKeyHash OCTET STRING,
71     *   serialNumber CertificateSerialNumber (INTEGER)
72     * }
73     */
74    public function buildOcspRequest(string $derCert, string $derIssuerCert): string
75    {
76        $issuerNameHash = CertificateUtils::getIssuerNameHash($derCert, $derIssuerCert);
77        $issuerKeyHash = CertificateUtils::getIssuerKeyHash($derIssuerCert);
78        $serialNumber = CertificateUtils::getSerialNumberDer($derCert);
79
80        // AlgorithmIdentifier for SHA-256
81        $algId = self::derSequence(
82            self::derOid(self::OID_SHA256) . self::derNull(),
83        );
84
85        // CertID
86        $certId = self::derSequence(
87            $algId
88            . self::derOctetString($issuerNameHash)
89            . self::derOctetString($issuerKeyHash)
90            . self::derInteger($serialNumber),
91        );
92
93        // Request
94        $request = self::derSequence($certId);
95
96        // requestList (SEQUENCE OF Request)
97        $requestList = self::derSequence($request);
98
99        // TBSRequest (version omitted = v1 default)
100        $tbsRequest = self::derSequence($requestList);
101
102        // OCSPRequest
103        return self::derSequence($tbsRequest);
104    }
105
106    /**
107     * Parse an OCSPResponse and validate the response status.
108     *
109     * OCSPResponse ::= SEQUENCE {
110     *   responseStatus OCSPResponseStatus (ENUMERATED),
111     *   responseBytes  [0] EXPLICIT ResponseBytes OPTIONAL
112     * }
113     *
114     * OCSPResponseStatus ::= ENUMERATED {
115     *   successful(0), malformedRequest(1), internalError(2),
116     *   tryLater(3), sigRequired(5), unauthorized(6)
117     * }
118     *
119     * @throws \RuntimeException if status is not successful(0)
120     */
121    public function parseOcspResponse(string $derResponse): void
122    {
123        $len = strlen($derResponse);
124        if ($len < 2) {
125            throw new \RuntimeException('OCSP response too short');
126        }
127
128        $pos = 0;
129
130        // Outer SEQUENCE
131        if (ord($derResponse[$pos]) !== 0x30) {
132            throw new \RuntimeException(sprintf(
133                'OCSP response: expected SEQUENCE (0x30), got 0x%02X',
134                ord($derResponse[$pos]),
135            ));
136        }
137        $pos++;
138        self::readDerLength($derResponse, $pos, $len);
139
140        // responseStatus ENUMERATED
141        if ($pos >= $len || ord($derResponse[$pos]) !== 0x0A) {
142            throw new \RuntimeException('OCSP response: expected ENUMERATED for responseStatus');
143        }
144        $pos++;
145        $statusLen = self::readDerLength($derResponse, $pos, $len);
146
147        $statusValue = 0;
148        for ($i = 0; $i < $statusLen; $i++) {
149            $statusValue = ($statusValue << 8) | ord($derResponse[$pos + $i]);
150        }
151
152        if ($statusValue !== 0) {
153            $statusNames = [
154                1 => 'malformedRequest',
155                2 => 'internalError',
156                3 => 'tryLater',
157                5 => 'sigRequired',
158                6 => 'unauthorized',
159            ];
160            $name = $statusNames[$statusValue] ?? "unknown($statusValue)";
161            throw new \RuntimeException("OCSP responder returned status: $name");
162        }
163    }
164
165    /**
166     * Send an OCSP request via HTTP POST.
167     */
168    private function sendRequest(string $url, string $requestBody): string
169    {
170        self::assertHttpUrl($url);
171
172        $ch = curl_init($url);
173        if ($ch === false) {
174            throw new \RuntimeException('Failed to initialize cURL for OCSP request');
175        }
176
177        curl_setopt_array($ch, [
178            CURLOPT_POST => true,
179            CURLOPT_POSTFIELDS => $requestBody,
180            CURLOPT_HTTPHEADER => [
181                'Content-Type: application/ocsp-request',
182                'Accept: application/ocsp-response',
183            ],
184            CURLOPT_RETURNTRANSFER => true,
185            CURLOPT_TIMEOUT => $this->timeout,
186            CURLOPT_FOLLOWLOCATION => true,
187            CURLOPT_MAXREDIRS => 3,
188            CURLOPT_PROTOCOLS => CURLPROTO_HTTP | CURLPROTO_HTTPS,
189            CURLOPT_REDIR_PROTOCOLS => CURLPROTO_HTTP | CURLPROTO_HTTPS,
190        ]);
191
192        $response = curl_exec($ch);
193        $httpCode = curl_getinfo($ch, CURLINFO_HTTP_CODE);
194        $error = curl_error($ch);
195        curl_close($ch);
196
197        if ($response === false) {
198            throw new \RuntimeException("OCSP request failed: $error");
199        }
200
201        if ($httpCode !== 200) {
202            throw new \RuntimeException("OCSP responder returned HTTP $httpCode");
203        }
204
205        return (string) $response;
206    }
207
208    private static function assertHttpUrl(string $url): void
209    {
210        $scheme = strtolower((string) parse_url($url, PHP_URL_SCHEME));
211        if ($scheme !== 'http' && $scheme !== 'https') {
212            throw new \InvalidArgumentException("Only HTTP and HTTPS OCSP URLs are allowed: $url");
213        }
214    }
215
216    // ------------------------------------------------------------------
217    // ASN.1 DER encoding helpers (same pattern as TsaClient)
218    // ------------------------------------------------------------------
219
220    private static function derTlv(int $tag, string $value): string
221    {
222        return chr($tag) . self::derLength(strlen($value)) . $value;
223    }
224
225    private static function derLength(int $len): string
226    {
227        if ($len < 0x80) {
228            return chr($len);
229        }
230        if ($len < 0x100) {
231            return "\x81" . chr($len);
232        }
233        if ($len < 0x10000) {
234            return "\x82" . pack('n', $len);
235        }
236        return "\x83" . chr(($len >> 16) & 0xFF) . pack('n', $len & 0xFFFF);
237    }
238
239    private static function readDerLength(string $data, int &$pos, int $dataLen): int
240    {
241        if ($pos >= $dataLen) {
242            throw new \RuntimeException('DER: unexpected end of data reading length');
243        }
244        $byte = ord($data[$pos]);
245        $pos++;
246
247        if ($byte < 0x80) {
248            return $byte;
249        }
250
251        $numBytes = $byte & 0x7F;
252        if ($numBytes === 0 || $pos + $numBytes > $dataLen) {
253            throw new \RuntimeException('DER: invalid length encoding');
254        }
255
256        $len = 0;
257        for ($i = 0; $i < $numBytes; $i++) {
258            $len = ($len << 8) | ord($data[$pos]);
259            $pos++;
260        }
261        return $len;
262    }
263
264    private static function derSequence(string $content): string
265    {
266        return self::derTlv(0x30, $content);
267    }
268
269    private static function derOid(string $oidBytes): string
270    {
271        return self::derTlv(0x06, $oidBytes);
272    }
273
274    private static function derNull(): string
275    {
276        return "\x05\x00";
277    }
278
279    private static function derOctetString(string $data): string
280    {
281        return self::derTlv(0x04, $data);
282    }
283
284    /**
285     * Encode raw bytes as a DER INTEGER.
286     *
287     * @param string $bytes Raw big-endian integer bytes (already properly encoded)
288     */
289    private static function derInteger(string $bytes): string
290    {
291        return self::derTlv(0x02, $bytes);
292    }
293}