Code Coverage
 
Lines
Functions and Methods
Classes and Traits
Total
82.96% covered (warning)
82.96%
112 / 135
70.59% covered (warning)
70.59%
12 / 17
CRAP
0.00% covered (danger)
0.00%
0 / 1
TsaClient
82.96% covered (warning)
82.96%
112 / 135
70.59% covered (warning)
70.59%
12 / 17
63.86
0.00% covered (danger)
0.00%
0 / 1
 __construct
100.00% covered (success)
100.00%
7 / 7
100.00% covered (success)
100.00%
1 / 1
1
 timestamp
75.00% covered (warning)
75.00%
3 / 4
0.00% covered (danger)
0.00%
0 / 1
1.02
 buildTimeStampReq
100.00% covered (success)
100.00%
13 / 13
100.00% covered (success)
100.00%
1 / 1
2
 parseTimeStampResp
90.62% covered (success)
90.62%
29 / 32
0.00% covered (danger)
0.00%
0 / 1
10.08
 sendRequest
82.76% covered (warning)
82.76%
24 / 29
0.00% covered (danger)
0.00%
0 / 1
5.13
 assertHttpUrl
100.00% covered (success)
100.00%
3 / 3
100.00% covered (success)
100.00%
1 / 1
3
 getOidBytes
100.00% covered (success)
100.00%
5 / 5
100.00% covered (success)
100.00%
1 / 1
5
 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%
10 / 10
100.00% covered (success)
100.00%
1 / 1
5
 derBoolean
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
2
 randomNonce
100.00% covered (success)
100.00%
5 / 5
100.00% covered (success)
100.00%
1 / 1
2
1<?php
2
3declare(strict_types=1);
4
5namespace Phpdftk\Pdf\Core\Interactive\Signature;
6
7/**
8 * RFC 3161 Time-Stamp Authority (TSA) HTTP client.
9 *
10 * Sends a TimeStampReq to a TSA server and returns the raw
11 * DER-encoded TimeStampToken suitable for embedding in a
12 * {@see DocTimeStamp}'s /Contents entry.
13 *
14 * The request is built using minimal ASN.1 DER encoding (no
15 * external ASN.1 library required). The hash algorithm defaults
16 * to SHA-256 (OID 2.16.840.1.101.3.4.2.1).
17 *
18 * Usage:
19 *   $tsa = new TsaClient('http://timestamp.example.com/tsa');
20 *   $token = $tsa->timestamp($dataToTimestamp);
21 *
22 * For integration with PdfFileWriter's signing pipeline, use
23 * {@see self::createTimestampSigner()} which returns a closure
24 * compatible with the signer callback interface.
25 *
26 * @see https://www.rfc-editor.org/rfc/rfc3161 RFC 3161
27 */
28final class TsaClient
29{
30    /** SHA-256 OID: 2.16.840.1.101.3.4.2.1 */
31    private const OID_SHA256 = "\x60\x86\x48\x01\x65\x03\x04\x02\x01";
32
33    /** SHA-384 OID: 2.16.840.1.101.3.4.2.2 */
34    private const OID_SHA384 = "\x60\x86\x48\x01\x65\x03\x04\x02\x02";
35
36    /** SHA-512 OID: 2.16.840.1.101.3.4.2.3 */
37    private const OID_SHA512 = "\x60\x86\x48\x01\x65\x03\x04\x02\x03";
38
39    private string $url;
40    private string $hashAlgorithm;
41    private ?string $username;
42    private ?string $password;
43    private int $timeout;
44    private bool $requestCert;
45
46    /**
47     * @param string $url           TSA server URL (HTTP or HTTPS)
48     * @param string $hashAlgorithm Hash algorithm: 'sha256', 'sha384', or 'sha512'
49     * @param string|null $username HTTP Basic auth username (optional)
50     * @param string|null $password HTTP Basic auth password (optional)
51     * @param int    $timeout       HTTP timeout in seconds
52     * @param bool   $requestCert   Whether to request the TSA certificate in the response
53     */
54    public function __construct(
55        string $url,
56        string $hashAlgorithm = 'sha256',
57        ?string $username = null,
58        ?string $password = null,
59        int $timeout = 30,
60        bool $requestCert = true,
61    ) {
62        self::assertHttpUrl($url);
63        $this->url = $url;
64        $this->hashAlgorithm = strtolower($hashAlgorithm);
65        $this->username = $username;
66        $this->password = $password;
67        $this->timeout = $timeout;
68        $this->requestCert = $requestCert;
69    }
70
71    /**
72     * Request a timestamp token for the given data.
73     *
74     * @param string $data The data to be timestamped (typically the signed
75     *                     byte ranges from the PDF)
76     * @return string Raw DER-encoded TimeStampToken (RFC 3161 §2.4.2)
77     * @throws \RuntimeException on network error, invalid response, or TSA rejection
78     */
79    public function timestamp(string $data): string
80    {
81        $hash = hash($this->hashAlgorithm, $data, binary: true);
82        $request = $this->buildTimeStampReq($hash);
83        $responseBody = $this->sendRequest($request);
84        return $this->parseTimeStampResp($responseBody);
85    }
86
87    /**
88     * Build an RFC 3161 TimeStampReq in DER format.
89     *
90     * TimeStampReq ::= SEQUENCE {
91     *   version          INTEGER { v1(1) },
92     *   messageImprint   MessageImprint,
93     *   reqPolicy        OBJECT IDENTIFIER  OPTIONAL,
94     *   nonce            INTEGER             OPTIONAL,
95     *   certReq          BOOLEAN             DEFAULT FALSE,
96     *   extensions   [0] IMPLICIT Extensions OPTIONAL
97     * }
98     *
99     * MessageImprint ::= SEQUENCE {
100     *   hashAlgorithm    AlgorithmIdentifier,
101     *   hashedMessage    OCTET STRING
102     * }
103     */
104    public function buildTimeStampReq(string $hash): string
105    {
106        $oid = $this->getOidBytes();
107
108        // AlgorithmIdentifier ::= SEQUENCE { algorithm OID, parameters NULL }
109        $algId = self::derSequence(
110            self::derOid($oid) . self::derNull(),
111        );
112
113        // MessageImprint
114        $messageImprint = self::derSequence(
115            $algId . self::derOctetString($hash),
116        );
117
118        // version INTEGER v1(1)
119        $version = self::derInteger(1);
120
121        // nonce — 8 random bytes for replay protection
122        $nonce = self::derInteger(self::randomNonce());
123
124        // certReq BOOLEAN
125        $certReq = $this->requestCert ? self::derBoolean(true) : '';
126
127        // TimeStampReq
128        return self::derSequence(
129            $version . $messageImprint . $nonce . $certReq,
130        );
131    }
132
133    /**
134     * Parse an RFC 3161 TimeStampResp and extract the TimeStampToken.
135     *
136     * TimeStampResp ::= SEQUENCE {
137     *   status     PKIStatusInfo,
138     *   timeStampToken  TimeStampToken OPTIONAL
139     * }
140     *
141     * PKIStatusInfo ::= SEQUENCE {
142     *   status        PKIStatus,
143     *   statusString  PKIFreeText     OPTIONAL,
144     *   failInfo      PKIFailureInfo  OPTIONAL
145     * }
146     *
147     * PKIStatus ::= INTEGER {
148     *   granted(0), grantedWithMods(1), rejection(2),
149     *   waiting(3), revocationWarning(4), revocationNotification(5)
150     * }
151     *
152     * @return string DER-encoded TimeStampToken (ContentInfo wrapping SignedData)
153     */
154    public function parseTimeStampResp(string $resp): string
155    {
156        $len = strlen($resp);
157        if ($len < 2) {
158            throw new \RuntimeException('TSA response too short');
159        }
160
161        // Outer SEQUENCE
162        $pos = 0;
163        $outerTag = ord($resp[$pos]);
164        if ($outerTag !== 0x30) {
165            throw new \RuntimeException(sprintf('TSA response: expected SEQUENCE (0x30), got 0x%02X', $outerTag));
166        }
167        $pos++;
168        self::readDerLength($resp, $pos, $len);
169
170        // PKIStatusInfo SEQUENCE
171        if ($pos >= $len || ord($resp[$pos]) !== 0x30) {
172            throw new \RuntimeException('TSA response: expected PKIStatusInfo SEQUENCE');
173        }
174        $pos++;
175        $statusInfoLen = self::readDerLength($resp, $pos, $len);
176        $statusInfoEnd = $pos + $statusInfoLen;
177
178        // PKIStatus INTEGER
179        if ($pos >= $statusInfoEnd || ord($resp[$pos]) !== 0x02) {
180            throw new \RuntimeException('TSA response: expected PKIStatus INTEGER');
181        }
182        $pos++;
183        $statusLen = self::readDerLength($resp, $pos, $len);
184        $statusValue = 0;
185        for ($i = 0; $i < $statusLen; $i++) {
186            $statusValue = ($statusValue << 8) | ord($resp[$pos + $i]);
187        }
188        $pos = $statusInfoEnd; // skip rest of PKIStatusInfo
189
190        // Status 0 = granted, 1 = grantedWithMods
191        if ($statusValue > 1) {
192            $statusNames = [
193                2 => 'rejection', 3 => 'waiting',
194                4 => 'revocationWarning', 5 => 'revocationNotification',
195            ];
196            $name = $statusNames[$statusValue] ?? "unknown($statusValue)";
197            throw new \RuntimeException("TSA returned status: $name");
198        }
199
200        // The remainder is the TimeStampToken (ContentInfo SEQUENCE)
201        if ($pos >= $len) {
202            throw new \RuntimeException('TSA response: no TimeStampToken present (status was granted but token missing)');
203        }
204
205        return substr($resp, $pos);
206    }
207
208    /**
209     * Send the TimeStampReq to the TSA via HTTP POST.
210     */
211    private function sendRequest(string $requestBody): string
212    {
213        $headers = [
214            'Content-Type: application/timestamp-query',
215            'Accept: application/timestamp-reply',
216        ];
217
218        if ($this->username !== null) {
219            $headers[] = 'Authorization: Basic ' . base64_encode($this->username . ':' . ($this->password ?? ''));
220        }
221
222        $ch = curl_init($this->url);
223        if ($ch === false) {
224            throw new \RuntimeException('Failed to initialize cURL for TSA request');
225        }
226
227        curl_setopt_array($ch, [
228            CURLOPT_POST => true,
229            CURLOPT_POSTFIELDS => $requestBody,
230            CURLOPT_HTTPHEADER => $headers,
231            CURLOPT_RETURNTRANSFER => true,
232            CURLOPT_TIMEOUT => $this->timeout,
233            CURLOPT_FOLLOWLOCATION => true,
234            CURLOPT_MAXREDIRS => 3,
235            CURLOPT_PROTOCOLS => CURLPROTO_HTTP | CURLPROTO_HTTPS,
236            CURLOPT_REDIR_PROTOCOLS => CURLPROTO_HTTP | CURLPROTO_HTTPS,
237        ]);
238
239        $response = curl_exec($ch);
240        $httpCode = curl_getinfo($ch, CURLINFO_HTTP_CODE);
241        $error = curl_error($ch);
242        curl_close($ch);
243
244        if ($response === false) {
245            throw new \RuntimeException("TSA request failed: $error");
246        }
247
248        if ($httpCode !== 200) {
249            throw new \RuntimeException("TSA returned HTTP $httpCode");
250        }
251
252        return (string) $response;
253    }
254
255    private static function assertHttpUrl(string $url): void
256    {
257        $scheme = strtolower((string) parse_url($url, PHP_URL_SCHEME));
258        if ($scheme !== 'http' && $scheme !== 'https') {
259            throw new \InvalidArgumentException("Only HTTP and HTTPS TSA URLs are allowed: $url");
260        }
261    }
262
263    // ------------------------------------------------------------------
264    // ASN.1 DER encoding helpers
265    // ------------------------------------------------------------------
266
267    private function getOidBytes(): string
268    {
269        return match ($this->hashAlgorithm) {
270            'sha256' => self::OID_SHA256,
271            'sha384' => self::OID_SHA384,
272            'sha512' => self::OID_SHA512,
273            default => throw new \InvalidArgumentException("Unsupported hash algorithm: {$this->hashAlgorithm}"),
274        };
275    }
276
277    /** Encode a DER tag + length + value. */
278    private static function derTlv(int $tag, string $value): string
279    {
280        return chr($tag) . self::derLength(strlen($value)) . $value;
281    }
282
283    /** Encode a DER length. */
284    private static function derLength(int $len): string
285    {
286        if ($len < 0x80) {
287            return chr($len);
288        }
289        if ($len < 0x100) {
290            return "\x81" . chr($len);
291        }
292        if ($len < 0x10000) {
293            return "\x82" . pack('n', $len);
294        }
295        return "\x83" . chr(($len >> 16) & 0xFF) . pack('n', $len & 0xFFFF);
296    }
297
298    /** Read a DER length from a buffer. Advances $pos past the length bytes. */
299    private static function readDerLength(string $data, int &$pos, int $dataLen): int
300    {
301        if ($pos >= $dataLen) {
302            throw new \RuntimeException('DER: unexpected end of data reading length');
303        }
304        $byte = ord($data[$pos]);
305        $pos++;
306
307        if ($byte < 0x80) {
308            return $byte;
309        }
310
311        $numBytes = $byte & 0x7F;
312        if ($numBytes === 0 || $pos + $numBytes > $dataLen) {
313            throw new \RuntimeException('DER: invalid length encoding');
314        }
315
316        $len = 0;
317        for ($i = 0; $i < $numBytes; $i++) {
318            $len = ($len << 8) | ord($data[$pos]);
319            $pos++;
320        }
321        return $len;
322    }
323
324    private static function derSequence(string $content): string
325    {
326        return self::derTlv(0x30, $content);
327    }
328
329    private static function derOid(string $oidBytes): string
330    {
331        return self::derTlv(0x06, $oidBytes);
332    }
333
334    private static function derNull(): string
335    {
336        return "\x05\x00";
337    }
338
339    private static function derOctetString(string $data): string
340    {
341        return self::derTlv(0x04, $data);
342    }
343
344    private static function derInteger(int $value): string
345    {
346        if ($value >= 0 && $value <= 127) {
347            return self::derTlv(0x02, chr($value));
348        }
349
350        // Encode as big-endian bytes
351        $bytes = '';
352        $tmp = $value;
353        while ($tmp > 0) {
354            $bytes = chr($tmp & 0xFF) . $bytes;
355            $tmp >>= 8;
356        }
357        // Ensure positive — prepend 0x00 if high bit set
358        if (ord($bytes[0]) & 0x80) {
359            $bytes = "\x00" . $bytes;
360        }
361        return self::derTlv(0x02, $bytes);
362    }
363
364    private static function derBoolean(bool $value): string
365    {
366        return self::derTlv(0x01, $value ? "\xFF" : "\x00");
367    }
368
369    /** Generate a random nonce for replay protection. */
370    private static function randomNonce(): int
371    {
372        // Use 7 bytes to stay within PHP int range on 64-bit
373        $bytes = random_bytes(7);
374        $nonce = 0;
375        for ($i = 0; $i < 7; $i++) {
376            $nonce = ($nonce << 8) | ord($bytes[$i]);
377        }
378        return $nonce;
379    }
380}