Code Coverage
 
Lines
Functions and Methods
Classes and Traits
Total
85.55% covered (warning)
85.55%
219 / 256
43.75% covered (danger)
43.75%
7 / 16
CRAP
0.00% covered (danger)
0.00%
0 / 1
CertificateUtils
85.55% covered (warning)
85.55%
219 / 256
43.75% covered (danger)
43.75%
7 / 16
102.30
0.00% covered (danger)
0.00%
0 / 1
 extractCertsFromPkcs7Der
83.87% covered (warning)
83.87%
52 / 62
0.00% covered (danger)
0.00%
0 / 1
11.51
 pemToDer
100.00% covered (success)
100.00%
6 / 6
100.00% covered (success)
100.00%
1 / 1
3
 derToPem
100.00% covered (success)
100.00%
3 / 3
100.00% covered (success)
100.00%
1 / 1
1
 getOcspResponderUrl
80.00% covered (warning)
80.00%
8 / 10
0.00% covered (danger)
0.00%
0 / 1
4.13
 getCrlDistributionPointUrls
92.31% covered (success)
92.31%
12 / 13
0.00% covered (danger)
0.00%
0 / 1
7.02
 getIssuerNameHash
100.00% covered (success)
100.00%
2 / 2
100.00% covered (success)
100.00%
1 / 1
1
 getIssuerKeyHash
100.00% covered (success)
100.00%
2 / 2
100.00% covered (success)
100.00%
1 / 1
1
 getSerialNumberDer
73.33% covered (warning)
73.33%
11 / 15
0.00% covered (danger)
0.00%
0 / 1
7.93
 buildChain
75.44% covered (warning)
75.44%
43 / 57
0.00% covered (danger)
0.00%
0 / 1
32.53
 ensurePem
100.00% covered (success)
100.00%
3 / 3
100.00% covered (success)
100.00%
1 / 1
2
 dnToString
75.00% covered (warning)
75.00%
6 / 8
0.00% covered (danger)
0.00%
0 / 1
4.25
 extractSubjectDer
100.00% covered (success)
100.00%
19 / 19
100.00% covered (success)
100.00%
1 / 1
3
 extractPublicKeyBits
100.00% covered (success)
100.00%
25 / 25
100.00% covered (success)
100.00%
1 / 1
3
 expectTag
91.67% covered (success)
91.67%
11 / 12
0.00% covered (danger)
0.00%
0 / 1
3.01
 readDerLength
85.71% covered (warning)
85.71%
12 / 14
0.00% covered (danger)
0.00%
0 / 1
6.10
 skipTlv
80.00% covered (warning)
80.00%
4 / 5
0.00% covered (danger)
0.00%
0 / 1
2.03
1<?php
2
3declare(strict_types=1);
4
5namespace Phpdftk\Pdf\Core\Interactive\Signature;
6
7/**
8 * X.509 certificate utilities for LTV signature support.
9 *
10 * Provides certificate chain extraction from PKCS#7 blobs, PEM/DER
11 * conversion, OCSP responder URL extraction, CRL distribution point
12 * parsing, and certificate chain ordering.
13 *
14 * Uses PHP's OpenSSL extension and inline ASN.1 DER parsing (same
15 * pattern as {@see TsaClient}).
16 */
17final class CertificateUtils
18{
19    /**
20     * Extract DER-encoded X.509 certificates from a PKCS#7 SignedData blob.
21     *
22     * Parses the ContentInfo > SignedData > certificates [0] IMPLICIT SET
23     * structure to extract all embedded certificates.
24     *
25     * @param string $derPkcs7 Raw DER-encoded PKCS#7 SignedData (hex-decoded /Contents value)
26     * @return list<string> Array of DER-encoded X.509 certificates
27     * @throws \RuntimeException if the structure cannot be parsed
28     */
29    public static function extractCertsFromPkcs7Der(string $derPkcs7): array
30    {
31        $len = strlen($derPkcs7);
32        if ($len < 2) {
33            throw new \RuntimeException('PKCS#7 data too short');
34        }
35
36        $pos = 0;
37
38        // ContentInfo SEQUENCE
39        self::expectTag($derPkcs7, $pos, 0x30, 'ContentInfo SEQUENCE');
40        $pos++;
41        self::readDerLength($derPkcs7, $pos, $len);
42
43        // contentType OID (1.2.840.113549.1.7.2 = signedData)
44        self::expectTag($derPkcs7, $pos, 0x06, 'contentType OID');
45        $pos++;
46        $oidLen = self::readDerLength($derPkcs7, $pos, $len);
47        $pos += $oidLen; // skip OID bytes
48
49        // content [0] EXPLICIT
50        if ($pos >= $len) {
51            throw new \RuntimeException('PKCS#7: unexpected end after contentType');
52        }
53        $tag = ord($derPkcs7[$pos]);
54        if ($tag !== 0xA0) {
55            throw new \RuntimeException(sprintf('PKCS#7: expected [0] EXPLICIT (0xA0), got 0x%02X', $tag));
56        }
57        $pos++;
58        self::readDerLength($derPkcs7, $pos, $len);
59
60        // SignedData SEQUENCE
61        self::expectTag($derPkcs7, $pos, 0x30, 'SignedData SEQUENCE');
62        $pos++;
63        $signedDataLen = self::readDerLength($derPkcs7, $pos, $len);
64        $signedDataEnd = $pos + $signedDataLen;
65
66        // version INTEGER
67        self::expectTag($derPkcs7, $pos, 0x02, 'version INTEGER');
68        $pos++;
69        $vLen = self::readDerLength($derPkcs7, $pos, $len);
70        $pos += $vLen;
71
72        // digestAlgorithms SET
73        self::expectTag($derPkcs7, $pos, 0x31, 'digestAlgorithms SET');
74        $pos++;
75        $daLen = self::readDerLength($derPkcs7, $pos, $len);
76        $pos += $daLen;
77
78        // encapContentInfo SEQUENCE
79        self::expectTag($derPkcs7, $pos, 0x30, 'encapContentInfo SEQUENCE');
80        $pos++;
81        $eciLen = self::readDerLength($derPkcs7, $pos, $len);
82        $pos += $eciLen;
83
84        // Now we should be at certificates [0] IMPLICIT or crls [1] or signerInfos SET
85        $certs = [];
86        while ($pos < $signedDataEnd) {
87            $tag = ord($derPkcs7[$pos]);
88
89            if ($tag === 0xA0) {
90                // certificates [0] IMPLICIT SET OF Certificate
91                $pos++;
92                $certsLen = self::readDerLength($derPkcs7, $pos, $len);
93                $certsEnd = $pos + $certsLen;
94
95                while ($pos < $certsEnd) {
96                    // Each certificate is a SEQUENCE
97                    if (ord($derPkcs7[$pos]) !== 0x30) {
98                        break;
99                    }
100                    $certStart = $pos;
101                    $pos++;
102                    $certBodyLen = self::readDerLength($derPkcs7, $pos, $len);
103                    $pos += $certBodyLen;
104                    $certs[] = substr($derPkcs7, $certStart, $pos - $certStart);
105                }
106                $pos = $certsEnd;
107            } elseif ($tag === 0xA1) {
108                // crls [1] IMPLICIT â€” skip
109                $pos++;
110                $crlsLen = self::readDerLength($derPkcs7, $pos, $len);
111                $pos += $crlsLen;
112            } elseif ($tag === 0x31) {
113                // signerInfos SET â€” we're done with certificates
114                break;
115            } else {
116                // Unknown tag â€” skip
117                $pos++;
118                $skipLen = self::readDerLength($derPkcs7, $pos, $len);
119                $pos += $skipLen;
120            }
121        }
122
123        if (empty($certs)) {
124            throw new \RuntimeException('No certificates found in PKCS#7 SignedData');
125        }
126
127        return $certs;
128    }
129
130    /**
131     * Convert a PEM-encoded certificate to DER.
132     */
133    public static function pemToDer(string $pem): string
134    {
135        $pem = preg_replace('/-----[A-Z ]+-----/', '', $pem) ?? '';
136        $pem = preg_replace('/\s+/', '', $pem) ?? '';
137        $der = base64_decode($pem, true);
138        if ($der === false || $der === '') {
139            throw new \RuntimeException('Failed to decode PEM to DER');
140        }
141        return $der;
142    }
143
144    /**
145     * Convert a DER-encoded certificate to PEM.
146     */
147    public static function derToPem(string $der): string
148    {
149        return "-----BEGIN CERTIFICATE-----\n"
150            . chunk_split(base64_encode($der), 64, "\n")
151            . "-----END CERTIFICATE-----\n";
152    }
153
154    /**
155     * Extract the OCSP responder URL from a certificate's Authority
156     * Information Access (AIA) extension.
157     *
158     * @param string $derOrPemCert DER or PEM certificate
159     * @return string|null OCSP responder URL, or null if not present
160     */
161    public static function getOcspResponderUrl(string $derOrPemCert): ?string
162    {
163        $pem = self::ensurePem($derOrPemCert);
164        $parsed = openssl_x509_parse($pem);
165        if ($parsed === false) {
166            return null;
167        }
168
169        // AIA is in extensions.authorityInfoAccess
170        $aia = $parsed['extensions']['authorityInfoAccess'] ?? null;
171        if ($aia === null) {
172            return null;
173        }
174
175        // Format: "OCSP - URI:http://ocsp.example.com\nCA Issuers - URI:http://..."
176        if (preg_match('/OCSP\s*-\s*URI:(\S+)/i', $aia, $m)) {
177            return $m[1];
178        }
179
180        return null;
181    }
182
183    /**
184     * Extract CRL Distribution Point URLs from a certificate.
185     *
186     * @param string $derOrPemCert DER or PEM certificate
187     * @return list<string> HTTP/HTTPS URLs
188     */
189    public static function getCrlDistributionPointUrls(string $derOrPemCert): array
190    {
191        $pem = self::ensurePem($derOrPemCert);
192        $parsed = openssl_x509_parse($pem);
193        if ($parsed === false) {
194            return [];
195        }
196
197        $cdp = $parsed['extensions']['crlDistributionPoints'] ?? null;
198        if ($cdp === null) {
199            return [];
200        }
201
202        // Format: "\nFull Name:\n  URI:http://crl.example.com/ca.crl\n..."
203        $urls = [];
204        if (preg_match_all('/URI:(\S+)/i', $cdp, $matches)) {
205            foreach ($matches[1] as $url) {
206                if (str_starts_with($url, 'http://') || str_starts_with($url, 'https://')) {
207                    $urls[] = $url;
208                }
209            }
210        }
211
212        return $urls;
213    }
214
215    /**
216     * Compute SHA-256 hash of the issuer's Distinguished Name (DER-encoded).
217     *
218     * Used in OCSP CertID.issuerNameHash.
219     */
220    public static function getIssuerNameHash(string $derCert, string $derIssuerCert): string
221    {
222        // The issuerNameHash is SHA-256 of the issuer's subject DN in DER form.
223        // We extract it by DER-walking the issuer certificate to find the
224        // subject field in the TBSCertificate.
225        $subjectDer = self::extractSubjectDer($derIssuerCert);
226        return hash('sha256', $subjectDer, binary: true);
227    }
228
229    /**
230     * Compute SHA-256 hash of the issuer's public key (DER-encoded, without tag/length).
231     *
232     * Used in OCSP CertID.issuerKeyHash.
233     */
234    public static function getIssuerKeyHash(string $derIssuerCert): string
235    {
236        $keyBits = self::extractPublicKeyBits($derIssuerCert);
237        return hash('sha256', $keyBits, binary: true);
238    }
239
240    /**
241     * Extract the serial number from a certificate as raw DER INTEGER content bytes.
242     */
243    public static function getSerialNumberDer(string $derOrPemCert): string
244    {
245        $pem = self::ensurePem($derOrPemCert);
246        $parsed = openssl_x509_parse($pem);
247        if ($parsed === false) {
248            throw new \RuntimeException('Cannot parse certificate');
249        }
250
251        $serial = $parsed['serialNumberHex'] ?? null;
252        if ($serial === null) {
253            throw new \RuntimeException('Certificate has no serial number');
254        }
255
256        // Pad to even length for hex2bin
257        if (strlen($serial) % 2 !== 0) {
258            $serial = '0' . $serial;
259        }
260
261        $bytes = hex2bin($serial);
262        if ($bytes === false) {
263            throw new \RuntimeException('Invalid serial number hex');
264        }
265
266        // Ensure positive integer encoding (prepend 0x00 if high bit set)
267        if (strlen($bytes) > 0 && (ord($bytes[0]) & 0x80)) {
268            $bytes = "\x00" . $bytes;
269        }
270
271        return $bytes;
272    }
273
274    /**
275     * Order certificates from leaf (signer) to root, by matching
276     * issuer/subject Distinguished Names.
277     *
278     * @param list<string> $derCerts Unordered DER-encoded certificates
279     * @return list<string> Ordered leaf→root
280     */
281    public static function buildChain(array $derCerts): array
282    {
283        if (count($derCerts) <= 1) {
284            return $derCerts;
285        }
286
287        // Parse subject and issuer for each cert
288        $certInfo = [];
289        foreach ($derCerts as $i => $der) {
290            $pem = self::derToPem($der);
291            $parsed = openssl_x509_parse($pem);
292            if ($parsed === false) {
293                continue;
294            }
295            $certInfo[$i] = [
296                'der' => $der,
297                'subject' => self::dnToString($parsed['subject'] ?? []),
298                'issuer' => self::dnToString($parsed['issuer'] ?? []),
299            ];
300        }
301
302        // Find the leaf: a cert whose subject is not the issuer of any other cert
303        $allIssuers = array_column($certInfo, 'issuer');
304        $leaf = null;
305        foreach ($certInfo as $i => $info) {
306            $isIssuerOfOther = false;
307            foreach ($certInfo as $j => $other) {
308                if ($i !== $j && $other['issuer'] === $info['subject']) {
309                    $isIssuerOfOther = true;
310                    break;
311                }
312            }
313            // Also skip self-signed (those are roots)
314            $isSelfSigned = $info['subject'] === $info['issuer'];
315            if (!$isIssuerOfOther && !$isSelfSigned) {
316                $leaf = $i;
317                break;
318            }
319        }
320
321        // If no clear leaf found (e.g., all self-signed), return as-is
322        if ($leaf === null) {
323            // Try again without the self-signed check
324            foreach ($certInfo as $i => $info) {
325                $isIssuerOfOther = false;
326                foreach ($certInfo as $j => $other) {
327                    if ($i !== $j && $other['issuer'] === $info['subject']) {
328                        $isIssuerOfOther = true;
329                        break;
330                    }
331                }
332                if (!$isIssuerOfOther) {
333                    $leaf = $i;
334                    break;
335                }
336            }
337        }
338
339        if ($leaf === null) {
340            return $derCerts;
341        }
342
343        // Build chain from leaf
344        $chain = [$certInfo[$leaf]['der']];
345        $used = [$leaf => true];
346        $currentIssuer = $certInfo[$leaf]['issuer'];
347
348        while (count($chain) < count($certInfo)) {
349            $found = false;
350            foreach ($certInfo as $i => $info) {
351                if (isset($used[$i])) {
352                    continue;
353                }
354                if ($info['subject'] === $currentIssuer) {
355                    $chain[] = $info['der'];
356                    $used[$i] = true;
357                    $currentIssuer = $info['issuer'];
358                    $found = true;
359                    break;
360                }
361            }
362            if (!$found) {
363                break;
364            }
365        }
366
367        // Append any remaining certs not in the chain
368        foreach ($certInfo as $i => $info) {
369            if (!isset($used[$i])) {
370                $chain[] = $info['der'];
371            }
372        }
373
374        return $chain;
375    }
376
377    // ------------------------------------------------------------------
378    // Internal helpers
379    // ------------------------------------------------------------------
380
381    /**
382     * Ensure input is PEM-encoded. If it looks like DER, convert.
383     */
384    private static function ensurePem(string $data): string
385    {
386        if (str_contains($data, '-----BEGIN')) {
387            return $data;
388        }
389        return self::derToPem($data);
390    }
391
392    /**
393     * Normalize a DN array to a comparable string.
394     *
395     * @param array<string, string|list<string>> $dn
396     */
397    private static function dnToString(array $dn): string
398    {
399        $parts = [];
400        foreach ($dn as $key => $value) {
401            if (is_array($value)) {
402                foreach ($value as $v) {
403                    $parts[] = "$key=$v";
404                }
405            } else {
406                $parts[] = "$key=$value";
407            }
408        }
409        sort($parts);
410        return implode(',', $parts);
411    }
412
413    /**
414     * Extract the subject Distinguished Name DER bytes from a certificate.
415     *
416     * Certificate ::= SEQUENCE {
417     *   tbsCertificate TBSCertificate,
418     *   ...
419     * }
420     * TBSCertificate ::= SEQUENCE {
421     *   version [0] EXPLICIT INTEGER OPTIONAL,
422     *   serialNumber INTEGER,
423     *   signature AlgorithmIdentifier,
424     *   issuer Name,
425     *   validity Validity,
426     *   subject Name,   <-- we want this
427     *   ...
428     * }
429     */
430    private static function extractSubjectDer(string $derCert): string
431    {
432        $len = strlen($derCert);
433        $pos = 0;
434
435        // Certificate SEQUENCE
436        self::expectTag($derCert, $pos, 0x30, 'Certificate');
437        $pos++;
438        self::readDerLength($derCert, $pos, $len);
439
440        // TBSCertificate SEQUENCE
441        self::expectTag($derCert, $pos, 0x30, 'TBSCertificate');
442        $pos++;
443        self::readDerLength($derCert, $pos, $len);
444
445        // version [0] EXPLICIT (optional)
446        if ($pos < $len && ord($derCert[$pos]) === 0xA0) {
447            $pos++;
448            $vLen = self::readDerLength($derCert, $pos, $len);
449            $pos += $vLen;
450        }
451
452        // serialNumber INTEGER
453        self::skipTlv($derCert, $pos, $len);
454
455        // signature AlgorithmIdentifier SEQUENCE
456        self::skipTlv($derCert, $pos, $len);
457
458        // issuer Name SEQUENCE
459        self::skipTlv($derCert, $pos, $len);
460
461        // validity SEQUENCE
462        self::skipTlv($derCert, $pos, $len);
463
464        // subject Name SEQUENCE â€” extract this
465        $subjectStart = $pos;
466        self::skipTlv($derCert, $pos, $len);
467        return substr($derCert, $subjectStart, $pos - $subjectStart);
468    }
469
470    /**
471     * Extract the raw public key bit string content from a certificate.
472     *
473     * After the subject in TBSCertificate comes subjectPublicKeyInfo:
474     * SubjectPublicKeyInfo ::= SEQUENCE {
475     *   algorithm AlgorithmIdentifier,
476     *   subjectPublicKey BIT STRING
477     * }
478     *
479     * We return the content of the BIT STRING (minus the unused-bits byte).
480     */
481    private static function extractPublicKeyBits(string $derCert): string
482    {
483        $len = strlen($derCert);
484        $pos = 0;
485
486        // Certificate SEQUENCE
487        self::expectTag($derCert, $pos, 0x30, 'Certificate');
488        $pos++;
489        self::readDerLength($derCert, $pos, $len);
490
491        // TBSCertificate SEQUENCE
492        self::expectTag($derCert, $pos, 0x30, 'TBSCertificate');
493        $pos++;
494        self::readDerLength($derCert, $pos, $len);
495
496        // version [0] EXPLICIT (optional)
497        if ($pos < $len && ord($derCert[$pos]) === 0xA0) {
498            $pos++;
499            $vLen = self::readDerLength($derCert, $pos, $len);
500            $pos += $vLen;
501        }
502
503        // serialNumber INTEGER
504        self::skipTlv($derCert, $pos, $len);
505        // signature AlgorithmIdentifier
506        self::skipTlv($derCert, $pos, $len);
507        // issuer Name
508        self::skipTlv($derCert, $pos, $len);
509        // validity
510        self::skipTlv($derCert, $pos, $len);
511        // subject Name
512        self::skipTlv($derCert, $pos, $len);
513
514        // subjectPublicKeyInfo SEQUENCE
515        self::expectTag($derCert, $pos, 0x30, 'SubjectPublicKeyInfo');
516        $pos++;
517        self::readDerLength($derCert, $pos, $len);
518
519        // algorithm AlgorithmIdentifier
520        self::skipTlv($derCert, $pos, $len);
521
522        // subjectPublicKey BIT STRING
523        self::expectTag($derCert, $pos, 0x03, 'subjectPublicKey BIT STRING');
524        $pos++;
525        $bsLen = self::readDerLength($derCert, $pos, $len);
526        // First byte is the unused-bits count (always 0 for keys)
527        return substr($derCert, $pos + 1, $bsLen - 1);
528    }
529
530    // ------------------------------------------------------------------
531    // ASN.1 DER parsing helpers (same pattern as TsaClient)
532    // ------------------------------------------------------------------
533
534    private static function expectTag(string $data, int $pos, int $expected, string $context): void
535    {
536        $len = strlen($data);
537        if ($pos >= $len) {
538            throw new \RuntimeException("DER: unexpected end of data at $context");
539        }
540        $tag = ord($data[$pos]);
541        if ($tag !== $expected) {
542            throw new \RuntimeException(sprintf(
543                'DER: expected 0x%02X at %s, got 0x%02X at offset %d',
544                $expected,
545                $context,
546                $tag,
547                $pos,
548            ));
549        }
550    }
551
552    private static function readDerLength(string $data, int &$pos, int $dataLen): int
553    {
554        if ($pos >= $dataLen) {
555            throw new \RuntimeException('DER: unexpected end of data reading length');
556        }
557        $byte = ord($data[$pos]);
558        $pos++;
559
560        if ($byte < 0x80) {
561            return $byte;
562        }
563
564        $numBytes = $byte & 0x7F;
565        if ($numBytes === 0 || $pos + $numBytes > $dataLen) {
566            throw new \RuntimeException('DER: invalid length encoding');
567        }
568
569        $len = 0;
570        for ($i = 0; $i < $numBytes; $i++) {
571            $len = ($len << 8) | ord($data[$pos]);
572            $pos++;
573        }
574        return $len;
575    }
576
577    /**
578     * Skip a complete TLV (tag + length + value) at the current position.
579     */
580    private static function skipTlv(string $data, int &$pos, int $dataLen): void
581    {
582        if ($pos >= $dataLen) {
583            throw new \RuntimeException('DER: unexpected end of data skipping TLV');
584        }
585        $pos++; // skip tag
586        $len = self::readDerLength($data, $pos, $dataLen);
587        $pos += $len;
588    }
589}