Code Coverage |
||||||||||
Lines |
Functions and Methods |
Classes and Traits |
||||||||
| Total | |
82.96% |
112 / 135 |
|
70.59% |
12 / 17 |
CRAP | |
0.00% |
0 / 1 |
| TsaClient | |
82.96% |
112 / 135 |
|
70.59% |
12 / 17 |
63.86 | |
0.00% |
0 / 1 |
| __construct | |
100.00% |
7 / 7 |
|
100.00% |
1 / 1 |
1 | |||
| timestamp | |
75.00% |
3 / 4 |
|
0.00% |
0 / 1 |
1.02 | |||
| buildTimeStampReq | |
100.00% |
13 / 13 |
|
100.00% |
1 / 1 |
2 | |||
| parseTimeStampResp | |
90.62% |
29 / 32 |
|
0.00% |
0 / 1 |
10.08 | |||
| sendRequest | |
82.76% |
24 / 29 |
|
0.00% |
0 / 1 |
5.13 | |||
| assertHttpUrl | |
100.00% |
3 / 3 |
|
100.00% |
1 / 1 |
3 | |||
| getOidBytes | |
100.00% |
5 / 5 |
|
100.00% |
1 / 1 |
5 | |||
| derTlv | |
100.00% |
1 / 1 |
|
100.00% |
1 / 1 |
1 | |||
| derLength | |
28.57% |
2 / 7 |
|
0.00% |
0 / 1 |
9.83 | |||
| readDerLength | |
35.71% |
5 / 14 |
|
0.00% |
0 / 1 |
15.56 | |||
| derSequence | |
100.00% |
1 / 1 |
|
100.00% |
1 / 1 |
1 | |||
| derOid | |
100.00% |
1 / 1 |
|
100.00% |
1 / 1 |
1 | |||
| derNull | |
100.00% |
1 / 1 |
|
100.00% |
1 / 1 |
1 | |||
| derOctetString | |
100.00% |
1 / 1 |
|
100.00% |
1 / 1 |
1 | |||
| derInteger | |
100.00% |
10 / 10 |
|
100.00% |
1 / 1 |
5 | |||
| derBoolean | |
100.00% |
1 / 1 |
|
100.00% |
1 / 1 |
2 | |||
| randomNonce | |
100.00% |
5 / 5 |
|
100.00% |
1 / 1 |
2 | |||
| 1 | <?php |
| 2 | |
| 3 | declare(strict_types=1); |
| 4 | |
| 5 | namespace 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 | */ |
| 28 | final 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 | } |