Initial commit

This commit is contained in:
2026-08-20 11:41:17 +00:00
commit 6949695eac
2334 changed files with 646393 additions and 0 deletions
@@ -0,0 +1,226 @@
<?php
declare(strict_types=1);
/**
* ValidationMaterial.php
*
* @since 2026-07-15
* @category Library
* @package PdfSign
* @author Nicola Asuni <info@tecnick.com>
* @copyright 2026 Nicola Asuni - Tecnick.com LTD
* @license https://www.gnu.org/copyleft/lesser.html GNU-LGPL v3 (see LICENSE)
* @link https://github.com/tecnickcom/tc-lib-pdf-sign
*
* This file is part of tc-lib-pdf-sign software library.
*/
namespace Com\Tecnick\Pdf\Sign\Ltv;
use Com\Tecnick\Pdf\Sign\Exception;
use Com\Tecnick\Pdf\Sign\Ocsp\Client as OcspClient;
/**
* Com\Tecnick\Pdf\Sign\Ltv\ValidationMaterial
*
* Collects the long-term validation (LTV) material embedded in a PDF Document
* Security Store (DSS): the certificate DERs, OCSP responses, and CRLs. URL
* discovery uses the certificate AIA and CRL distribution point extensions;
* network retrieval is delegated to injected transport callables so this class
* stays testable and free of SSRF concerns. The VRI key (SHA-1 of the signature
* Contents) is intentionally not computed here: it belongs to the DSS writer,
* which holds the final signature bytes.
*
* @since 2026-07-15
* @category Library
* @package PdfSign
* @author Nicola Asuni <info@tecnick.com>
* @copyright 2026 Nicola Asuni - Tecnick.com LTD
* @license https://www.gnu.org/copyleft/lesser.html GNU-LGPL v3 (see LICENSE)
* @link https://github.com/tecnickcom/tc-lib-pdf-sign
*/
final class ValidationMaterial
{
private OcspClient $ocsp;
public function __construct(?OcspClient $ocsp = null)
{
$this->ocsp = $ocsp ?? new OcspClient();
}
/**
* Convert a list of PEM certificates to deduplicated DER strings.
*
* @param list<string> $certsPem
*
* @return list<string>
*
* @throws Exception If any certificate is not valid PEM.
*/
public function certificates(array $certsPem): array
{
$seen = [];
$result = [];
foreach ($certsPem as $pem) {
$der = $this->pemToDer($pem);
$fingerprint = \hash('sha256', $der);
if (isset($seen[$fingerprint])) {
continue;
}
$seen[$fingerprint] = true;
$result[] = $der;
}
return $result;
}
/**
* Extract the OCSP responder URLs from a certificate's AIA extension.
*
* Returns an empty list when the certificate has no AIA extension or cannot be
* parsed (LTV collection is best-effort; see extensionText).
*
* @return list<string>
*/
public function certificateOcspUrls(string $certPem): array
{
return $this->extractUris($this->extensionText($certPem, 'authorityInfoAccess'), '~OCSP\s*-\s*URI:(\S+)~i');
}
/**
* Extract the CRL distribution point URLs from a certificate.
*
* Returns an empty list when the certificate has no CRL distribution point or cannot
* be parsed (LTV collection is best-effort; see extensionText).
*
* @return list<string>
*/
public function certificateCrlUrls(string $certPem): array
{
return $this->extractUris($this->extensionText($certPem, 'crlDistributionPoints'), '~URI:(\S+)~');
}
/**
* Fetch OCSP responses for a certificate from the given responder URLs.
*
* @param list<string> $urls
* @param callable $transport Receives (url, DER request) and returns the DER response.
*
* @return list<string> Deduplicated OCSP response bytes.
*
* @throws Exception If the OCSP request cannot be built.
*/
public function fetchOcsp(string $issuerDer, string $leafDer, array $urls, callable $transport): array
{
if ($urls === []) {
return [];
}
$request = $this->ocsp->build($issuerDer, $leafDer);
return $this->fetchDeduplicated($urls, static fn(string $url): mixed => $transport($url, $request));
}
/**
* Fetch CRLs from the given distribution point URLs.
*
* @param list<string> $urls
* @param callable $transport Receives (url) and returns the CRL bytes.
*
* @return list<string> Deduplicated CRL bytes.
*/
public function fetchCrl(array $urls, callable $transport): array
{
return $this->fetchDeduplicated($urls, static fn(string $url): mixed => $transport($url));
}
/**
* Fetch each URL through the callback, skipping failures and duplicates.
*
* @param list<string> $urls
* @param callable(string): mixed $fetch
*
* @return list<string>
*/
private function fetchDeduplicated(array $urls, callable $fetch): array
{
$seen = [];
$result = [];
foreach ($urls as $url) {
try {
/** @var mixed $data */
$data = $fetch($url);
} catch (\Throwable) {
continue;
}
if (!\is_string($data) || $data === '') {
continue;
}
$fingerprint = \hash('sha256', $data);
if (isset($seen[$fingerprint])) {
continue;
}
$seen[$fingerprint] = true;
$result[] = $data;
}
return $result;
}
/**
* Return the human-readable text of a named certificate extension.
*
* LTV material collection is best-effort: a certificate whose extensions cannot be
* parsed (for example a legacy certificate with a negative serial that a strict
* OpenSSL build rejects) yields no extension text, so no OCSP/CRL URLs are derived
* from it, rather than aborting the whole signing operation. The certificate itself
* is still embedded, since its DER bytes are obtained separately (see pemToDer).
*/
private function extensionText(string $certPem, string $name): string
{
$info = \openssl_x509_parse($certPem);
if (!\is_array($info)) {
return '';
}
$extensions = $info['extensions'];
/** @var mixed $value */
$value = $extensions[$name] ?? '';
return \is_string($value) ? $value : '';
}
/**
* Extract and deduplicate the capture group 1 matches of a pattern.
*
* @return list<string>
*/
private function extractUris(string $text, string $pattern): array
{
$matches = [];
\preg_match_all($pattern, $text, $matches);
return \array_values(\array_unique($matches[1] ?? []));
}
/**
* Decode a PEM certificate to DER.
*
* @throws Exception If the PEM cannot be decoded.
*/
private function pemToDer(string $pem): string
{
$stripped = (string) \preg_replace('/-----[^-]+-----|\s+/', '', $pem);
$der = \base64_decode($stripped, true);
if ($der === false) {
throw new Exception('Invalid PEM certificate');
}
return $der;
}
}