generated from jric11/baseProject
Initial commit
This commit is contained in:
+317
@@ -0,0 +1,317 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
/**
|
||||
* Asn1.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\Cms;
|
||||
|
||||
use Com\Tecnick\Pdf\Sign\Exception;
|
||||
|
||||
/**
|
||||
* Com\Tecnick\Pdf\Sign\Cms\Asn1
|
||||
*
|
||||
* Minimal DER ASN.1 encoder/decoder used to assemble and inspect CMS/CAdES
|
||||
* structures, RFC 3161 timestamp messages, and OCSP requests. Only the subset
|
||||
* of ASN.1 needed by PDF signatures is implemented.
|
||||
*
|
||||
* @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
|
||||
*/
|
||||
class Asn1
|
||||
{
|
||||
/**
|
||||
* Encode a DER length octet sequence.
|
||||
*
|
||||
* @param int<0, max> $length Number of content octets.
|
||||
*
|
||||
* @throws Exception If the length is too large to encode.
|
||||
*/
|
||||
public function encodeLength(int $length): string
|
||||
{
|
||||
if ($length < 128) {
|
||||
return \chr($length);
|
||||
}
|
||||
|
||||
$encoded = '';
|
||||
$value = $length;
|
||||
while ($value > 0) {
|
||||
$encoded = \chr((int) ($value & 0xFF)) . $encoded;
|
||||
$value = (int) ($value / 256);
|
||||
}
|
||||
|
||||
$encodedLength = \strlen($encoded);
|
||||
if ($encodedLength > 0x7F) {
|
||||
// Defensive: unreachable, as this needs content larger than 2^1016
|
||||
// bytes, which is unrepresentable and unallocatable.
|
||||
throw new Exception('ASN.1 length encoding overflow');
|
||||
}
|
||||
|
||||
return \chr(0x80 | $encodedLength) . $encoded;
|
||||
}
|
||||
|
||||
/**
|
||||
* Encode a non-negative integer as a DER INTEGER.
|
||||
*
|
||||
* @param int<0, max> $value Integer value.
|
||||
*
|
||||
* @throws Exception If the length cannot be encoded.
|
||||
*/
|
||||
public function encodeInteger(int $value): string
|
||||
{
|
||||
$data = '';
|
||||
$num = $value;
|
||||
while ($num > 0) {
|
||||
$data = \chr((int) ($num & 0xFF)) . $data;
|
||||
$num = (int) ($num / 256);
|
||||
}
|
||||
|
||||
if ($data === '') {
|
||||
$data = "\x00";
|
||||
}
|
||||
|
||||
if ((\ord($data[0]) & 0x80) !== 0) {
|
||||
$data = "\x00" . $data;
|
||||
}
|
||||
|
||||
return "\x02" . $this->encodeLength(\strlen($data)) . $data;
|
||||
}
|
||||
|
||||
/**
|
||||
* Encode a big-endian magnitude byte string as a DER INTEGER.
|
||||
*
|
||||
* Trims superfluous leading zero octets and prepends a zero octet when the
|
||||
* most significant bit is set, so the value stays non-negative. Useful for
|
||||
* certificate serial numbers.
|
||||
*
|
||||
* @throws Exception If the length cannot be encoded.
|
||||
*/
|
||||
public function encodeIntegerBytes(string $bytes): string
|
||||
{
|
||||
$len = \strlen($bytes);
|
||||
$start = 0;
|
||||
while ($start < ($len - 1) && $bytes[$start] === "\x00" && (\ord($bytes[$start + 1]) & 0x80) === 0) {
|
||||
++$start;
|
||||
}
|
||||
|
||||
$magnitude = \substr($bytes, $start);
|
||||
if ($magnitude === '') {
|
||||
$magnitude = "\x00";
|
||||
}
|
||||
|
||||
if ((\ord($magnitude[0]) & 0x80) !== 0) {
|
||||
$magnitude = "\x00" . $magnitude;
|
||||
}
|
||||
|
||||
return "\x02" . $this->encodeLength(\strlen($magnitude)) . $magnitude;
|
||||
}
|
||||
|
||||
/**
|
||||
* Encode a DER BOOLEAN.
|
||||
*/
|
||||
public function encodeBoolean(bool $value): string
|
||||
{
|
||||
return "\x01\x01" . ($value ? "\xFF" : "\x00");
|
||||
}
|
||||
|
||||
/**
|
||||
* Encode a DER NULL.
|
||||
*/
|
||||
public function encodeNull(): string
|
||||
{
|
||||
return "\x05\x00";
|
||||
}
|
||||
|
||||
/**
|
||||
* Encode a DER OCTET STRING.
|
||||
*
|
||||
* @throws Exception If the length cannot be encoded.
|
||||
*/
|
||||
public function encodeOctetString(string $value): string
|
||||
{
|
||||
return "\x04" . $this->encodeLength(\strlen($value)) . $value;
|
||||
}
|
||||
|
||||
/**
|
||||
* Wrap pre-encoded content in a DER SEQUENCE.
|
||||
*
|
||||
* @throws Exception If the length cannot be encoded.
|
||||
*/
|
||||
public function encodeSequence(string $value): string
|
||||
{
|
||||
return "\x30" . $this->encodeLength(\strlen($value)) . $value;
|
||||
}
|
||||
|
||||
/**
|
||||
* Wrap pre-encoded content in a DER SET.
|
||||
*
|
||||
* @throws Exception If the length cannot be encoded.
|
||||
*/
|
||||
public function encodeSet(string $value): string
|
||||
{
|
||||
return "\x31" . $this->encodeLength(\strlen($value)) . $value;
|
||||
}
|
||||
|
||||
/**
|
||||
* Wrap pre-encoded content in a context-specific constructed tag [n].
|
||||
*
|
||||
* @param int<0, 30> $number Context tag number.
|
||||
*
|
||||
* @throws Exception If the length cannot be encoded.
|
||||
*/
|
||||
public function encodeContext(int $number, string $value): string
|
||||
{
|
||||
return \chr(0xA0 | ($number & 0x1F)) . $this->encodeLength(\strlen($value)) . $value;
|
||||
}
|
||||
|
||||
/**
|
||||
* Encode a dotted OID string as a DER OBJECT IDENTIFIER.
|
||||
*
|
||||
* @throws Exception If the OID is malformed or the length cannot be encoded.
|
||||
*/
|
||||
public function encodeObjectIdentifier(string $oid): string
|
||||
{
|
||||
$parts = \array_map('intval', \explode('.', $oid));
|
||||
if (\count($parts) < 2) {
|
||||
throw new Exception('Invalid OID');
|
||||
}
|
||||
|
||||
$data = \chr((int) ((($parts[0] * 40) + ($parts[1] ?? 0)) & 0xFF));
|
||||
$count = \count($parts);
|
||||
for ($idx = 2; $idx < $count; ++$idx) {
|
||||
$part = (int) ($parts[$idx] ?? 0);
|
||||
if ($part < 0) {
|
||||
$part = 0;
|
||||
}
|
||||
|
||||
$data .= $this->encodeBase128Int($part);
|
||||
}
|
||||
|
||||
return "\x06" . $this->encodeLength(\strlen($data)) . $data;
|
||||
}
|
||||
|
||||
/**
|
||||
* Encode a non-negative integer in base-128 with continuation bits.
|
||||
*
|
||||
* @param int<0, max> $value Integer value.
|
||||
*/
|
||||
public function encodeBase128Int(int $value): string
|
||||
{
|
||||
$bytes = [$value & 0x7F];
|
||||
$value = (int) ($value / 128);
|
||||
while ($value > 0) {
|
||||
\array_unshift($bytes, ($value & 0x7F) | 0x80);
|
||||
$value = (int) ($value / 128);
|
||||
}
|
||||
|
||||
$out = '';
|
||||
foreach ($bytes as $byte) {
|
||||
$out .= \chr($byte);
|
||||
}
|
||||
|
||||
return $out;
|
||||
}
|
||||
|
||||
/**
|
||||
* Read one DER TLV triplet starting at the given offset.
|
||||
*
|
||||
* @param int $offset Read cursor; advanced past the parsed element.
|
||||
*
|
||||
* @return array{tag: int, value: string, raw: string}
|
||||
*
|
||||
* @throws Exception If the structure or length is malformed.
|
||||
*/
|
||||
public function readTlv(string $data, int &$offset): array
|
||||
{
|
||||
if ($offset >= \strlen($data)) {
|
||||
throw new Exception('Malformed ASN.1 structure');
|
||||
}
|
||||
|
||||
$start = $offset;
|
||||
$tag = \ord($data[$offset]);
|
||||
++$offset;
|
||||
|
||||
$length = $this->readLength($data, $offset);
|
||||
if (($offset + $length) > \strlen($data)) {
|
||||
throw new Exception('Malformed ASN.1 length');
|
||||
}
|
||||
|
||||
$value = \substr($data, $offset, $length);
|
||||
$offset += $length;
|
||||
$raw = \substr($data, $start, $offset - $start);
|
||||
|
||||
return ['tag' => $tag, 'value' => $value, 'raw' => $raw];
|
||||
}
|
||||
|
||||
/**
|
||||
* Read a DER length starting at the given offset.
|
||||
*
|
||||
* @param int $offset Read cursor; advanced past the length octets.
|
||||
*
|
||||
* @throws Exception If the length is malformed or unsupported.
|
||||
*/
|
||||
public function readLength(string $data, int &$offset): int
|
||||
{
|
||||
if ($offset >= \strlen($data)) {
|
||||
throw new Exception('Malformed ASN.1 length');
|
||||
}
|
||||
|
||||
$first = \ord($data[$offset]);
|
||||
++$offset;
|
||||
if (($first & 0x80) === 0) {
|
||||
return $first;
|
||||
}
|
||||
|
||||
$numBytes = $first & 0x7F;
|
||||
if ($numBytes < 1 || $numBytes > 4 || ($offset + $numBytes) > \strlen($data)) {
|
||||
throw new Exception('Unsupported ASN.1 length');
|
||||
}
|
||||
|
||||
$length = 0;
|
||||
for ($idx = 0; $idx < $numBytes; ++$idx) {
|
||||
$length = ($length * 256) + \ord($data[$offset + $idx]);
|
||||
}
|
||||
|
||||
$offset += $numBytes;
|
||||
return $length;
|
||||
}
|
||||
|
||||
/**
|
||||
* Decode a DER INTEGER content string to a PHP integer.
|
||||
*
|
||||
* @param string $value Content octets (without tag/length).
|
||||
*
|
||||
* @throws Exception If the value is empty.
|
||||
*/
|
||||
public function decodeInteger(string $value): int
|
||||
{
|
||||
if ($value === '') {
|
||||
throw new Exception('Invalid ASN.1 integer');
|
||||
}
|
||||
|
||||
$int = 0;
|
||||
$len = \strlen($value);
|
||||
for ($idx = 0; $idx < $len; ++$idx) {
|
||||
$int = ($int * 256) + \ord($value[$idx]);
|
||||
}
|
||||
|
||||
return $int;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,347 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
/**
|
||||
* Builder.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\Cms;
|
||||
|
||||
use Com\Tecnick\Pdf\Sign\Exception;
|
||||
use OpenSSLAsymmetricKey;
|
||||
|
||||
/**
|
||||
* Com\Tecnick\Pdf\Sign\Cms\Builder
|
||||
*
|
||||
* Native builder for a detached CAdES-BES CMS SignedData, suitable for a
|
||||
* PAdES B-B signature (/SubFilter /ETSI.CAdES.detached). It assembles the
|
||||
* SignerInfo with the mandatory signed attributes (content-type,
|
||||
* message-digest, signing-time, and the ESS signing-certificate-v2 that plain
|
||||
* openssl_pkcs7_sign() cannot add), signs the DER SET OF signed attributes with
|
||||
* openssl_sign(), and encodes the ContentInfo. RSA and ECDSA keys are
|
||||
* supported with SHA-256/384/512.
|
||||
*
|
||||
* @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 Builder
|
||||
{
|
||||
private const OID_SIGNED_DATA = '1.2.840.113549.1.7.2';
|
||||
|
||||
private const OID_DATA = '1.2.840.113549.1.7.1';
|
||||
|
||||
private const OID_CONTENT_TYPE = '1.2.840.113549.1.9.3';
|
||||
|
||||
private const OID_MESSAGE_DIGEST = '1.2.840.113549.1.9.4';
|
||||
|
||||
private const OID_SIGNING_TIME = '1.2.840.113549.1.9.5';
|
||||
|
||||
private const OID_SIGNING_CERTIFICATE_V2 = '1.2.840.113549.1.9.16.2.47';
|
||||
|
||||
private const OID_SIGNATURE_TIMESTAMP = '1.2.840.113549.1.9.16.2.14';
|
||||
|
||||
private const OID_RSA_ENCRYPTION = '1.2.840.113549.1.1.1';
|
||||
|
||||
/**
|
||||
* Digest name to [digest OID, openssl algo constant, ecdsa-with-* OID].
|
||||
*
|
||||
* @var array<string, array{string, int, string}>
|
||||
*/
|
||||
private const DIGESTS = [
|
||||
'sha256' => ['2.16.840.1.101.3.4.2.1', OPENSSL_ALGO_SHA256, '1.2.840.10045.4.3.2'],
|
||||
'sha384' => ['2.16.840.1.101.3.4.2.2', OPENSSL_ALGO_SHA384, '1.2.840.10045.4.3.3'],
|
||||
'sha512' => ['2.16.840.1.101.3.4.2.3', OPENSSL_ALGO_SHA512, '1.2.840.10045.4.3.4'],
|
||||
];
|
||||
|
||||
private Asn1 $asn1;
|
||||
|
||||
public function __construct(?Asn1 $asn1 = null)
|
||||
{
|
||||
$this->asn1 = $asn1 ?? new Asn1();
|
||||
}
|
||||
|
||||
/**
|
||||
* Produce a detached CAdES-BES CMS SignedData over the given content.
|
||||
*
|
||||
* @param string $data Detached content bytes (the signed data).
|
||||
* @param string $signerCertDer DER of the signing certificate.
|
||||
* @param OpenSSLAsymmetricKey $privateKey Signing private key (RSA or EC).
|
||||
* @param list<string> $chainCertsDer Additional certificates (DER) to embed.
|
||||
* @param string $digestAlgorithm One of the DIGESTS keys.
|
||||
* @param int $signingTime Unix timestamp for the signing-time attribute.
|
||||
* @param (callable(string): string)|null $signatureTimestamp Optional provider that receives the
|
||||
* raw SignerInfo signature bytes and returns a DER-encoded RFC 3161
|
||||
* timestamp token (ContentInfo). When supplied, the token is embedded as
|
||||
* the id-aa-signatureTimeStampToken unsigned attribute (PAdES B-T).
|
||||
* @param bool $includeSigningTime Whether to add the CMS signing-time signed
|
||||
* attribute. The legacy (ISO 32000-1) profile includes it; PAdES-BASELINE
|
||||
* forbids it (ETSI EN 319 142-1) and carries the time in the /M signature
|
||||
* dictionary entry instead.
|
||||
*
|
||||
* @return string DER-encoded CMS ContentInfo.
|
||||
*
|
||||
* @throws Exception If the digest or key is unsupported, or signing fails.
|
||||
*/
|
||||
public function sign(
|
||||
string $data,
|
||||
string $signerCertDer,
|
||||
OpenSSLAsymmetricKey $privateKey,
|
||||
array $chainCertsDer,
|
||||
string $digestAlgorithm,
|
||||
int $signingTime,
|
||||
?callable $signatureTimestamp = null,
|
||||
bool $includeSigningTime = true,
|
||||
): string {
|
||||
[$digestOid, $opensslAlgo, $ecdsaOid] = $this->algorithms($digestAlgorithm);
|
||||
[$signatureOid, $signatureHasNullParams] = $this->signatureAlgorithm($privateKey, $ecdsaOid);
|
||||
|
||||
$messageDigest = \hash($digestAlgorithm, $data, true);
|
||||
$certHash = \hash($digestAlgorithm, $signerCertDer, true);
|
||||
|
||||
$signedAttributes = $this->signedAttributes(
|
||||
$messageDigest,
|
||||
$certHash,
|
||||
$digestAlgorithm,
|
||||
$digestOid,
|
||||
$signingTime,
|
||||
$includeSigningTime,
|
||||
);
|
||||
$signedAttributesForSigning = $this->asn1->encodeSet($signedAttributes);
|
||||
|
||||
$signature = '';
|
||||
if (!\openssl_sign($signedAttributesForSigning, $signature, $privateKey, $opensslAlgo)) {
|
||||
throw new Exception('Unable to sign the CMS signed attributes');
|
||||
}
|
||||
|
||||
$unsignedAttributes = $signatureTimestamp === null
|
||||
? ''
|
||||
: $this->signatureTimestampAttributes($signatureTimestamp, $signature);
|
||||
|
||||
$signerInfo = $this->asn1->encodeSequence(
|
||||
$this->asn1->encodeInteger(1)
|
||||
. $this->issuerAndSerialNumber($signerCertDer)
|
||||
. $this->algorithmIdentifier($digestOid, false)
|
||||
. $this->asn1->encodeContext(0, $signedAttributes)
|
||||
. $this->algorithmIdentifier($signatureOid, $signatureHasNullParams)
|
||||
. $this->asn1->encodeOctetString($signature)
|
||||
. $unsignedAttributes,
|
||||
);
|
||||
|
||||
$certificates = $this->asn1->encodeContext(0, $signerCertDer . \implode('', $chainCertsDer));
|
||||
|
||||
$signedData = $this->asn1->encodeSequence(
|
||||
$this->asn1->encodeInteger(1)
|
||||
. $this->asn1->encodeSet($this->algorithmIdentifier($digestOid, false))
|
||||
. $this->asn1->encodeSequence($this->asn1->encodeObjectIdentifier(self::OID_DATA))
|
||||
. $certificates
|
||||
. $this->asn1->encodeSet($signerInfo),
|
||||
);
|
||||
|
||||
return $this->asn1->encodeSequence(
|
||||
$this->asn1->encodeObjectIdentifier(self::OID_SIGNED_DATA) . $this->asn1->encodeContext(0, $signedData),
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolve the OIDs and openssl constant for a digest name.
|
||||
*
|
||||
* @return array{string, int, string} [digest OID, openssl algo, ecdsa OID]
|
||||
*
|
||||
* @throws Exception If the digest is unsupported.
|
||||
*/
|
||||
private function algorithms(string $digestAlgorithm): array
|
||||
{
|
||||
if (!isset(self::DIGESTS[$digestAlgorithm])) {
|
||||
throw new Exception('Unsupported digest algorithm: ' . $digestAlgorithm);
|
||||
}
|
||||
|
||||
return self::DIGESTS[$digestAlgorithm];
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolve the signature AlgorithmIdentifier for the signing key.
|
||||
*
|
||||
* @return array{string, bool} [signature OID, whether NULL parameters are emitted]
|
||||
*
|
||||
* @throws Exception If the key type is unsupported.
|
||||
*/
|
||||
private function signatureAlgorithm(OpenSSLAsymmetricKey $privateKey, string $ecdsaOid): array
|
||||
{
|
||||
$details = \openssl_pkey_get_details($privateKey);
|
||||
$type = $details !== false ? $details['type'] ?? -1 : -1;
|
||||
|
||||
if ($type === OPENSSL_KEYTYPE_RSA) {
|
||||
return [self::OID_RSA_ENCRYPTION, true];
|
||||
}
|
||||
|
||||
if ($type === OPENSSL_KEYTYPE_EC) {
|
||||
return [$ecdsaOid, false];
|
||||
}
|
||||
|
||||
throw new Exception('Unsupported signing key type');
|
||||
}
|
||||
|
||||
/**
|
||||
* Build the sorted DER SET OF signed attributes content (without the tag).
|
||||
*
|
||||
* @throws Exception If encoding fails.
|
||||
*/
|
||||
private function signedAttributes(
|
||||
string $messageDigest,
|
||||
string $certHash,
|
||||
string $digestAlgorithm,
|
||||
string $digestOid,
|
||||
int $signingTime,
|
||||
bool $includeSigningTime,
|
||||
): string {
|
||||
$attributes = [
|
||||
$this->attribute(self::OID_CONTENT_TYPE, $this->asn1->encodeObjectIdentifier(self::OID_DATA)),
|
||||
$this->attribute(self::OID_MESSAGE_DIGEST, $this->asn1->encodeOctetString($messageDigest)),
|
||||
$this->attribute(self::OID_SIGNING_CERTIFICATE_V2, $this->signingCertificateV2(
|
||||
$certHash,
|
||||
$digestAlgorithm,
|
||||
$digestOid,
|
||||
)),
|
||||
];
|
||||
|
||||
// The CMS signing-time attribute belongs to the legacy (ISO 32000-1) profile.
|
||||
// PAdES-BASELINE forbids it (ETSI EN 319 142-1): the signing time is carried by
|
||||
// the /M entry of the PDF signature dictionary, so validators demote a signature
|
||||
// that carries signing-time from PAdES-BASELINE-B to the older PAdES-BES format.
|
||||
if ($includeSigningTime) {
|
||||
$attributes[] = $this->attribute(self::OID_SIGNING_TIME, $this->encodeTime($signingTime));
|
||||
}
|
||||
|
||||
// DER requires the members of a SET OF to be sorted by their encoding,
|
||||
// compared as octet strings padded with trailing zero octets.
|
||||
\usort($attributes, static function (string $one, string $two): int {
|
||||
$length = \max(\strlen($one), \strlen($two));
|
||||
return \strcmp(\str_pad($one, $length, "\x00"), \str_pad($two, $length, "\x00"));
|
||||
});
|
||||
|
||||
return \implode('', $attributes);
|
||||
}
|
||||
|
||||
/**
|
||||
* Build the SignerInfo [1] IMPLICIT unsignedAttrs carrying the signature
|
||||
* timestamp.
|
||||
*
|
||||
* The provider computes an RFC 3161 token over the raw signature bytes
|
||||
* (CAdES id-aa-signatureTimeStampToken), which is then wrapped as a single
|
||||
* unsigned Attribute value.
|
||||
*
|
||||
* @param callable(string): string $provider Maps the signature bytes to a DER token.
|
||||
* @param string $signature Raw SignerInfo signature bytes.
|
||||
*
|
||||
* @throws Exception If the provider yields an empty or non-string token, or encoding fails.
|
||||
*/
|
||||
private function signatureTimestampAttributes(callable $provider, string $signature): string
|
||||
{
|
||||
/** @var mixed $token */
|
||||
$token = $provider($signature);
|
||||
if (!\is_string($token) || $token === '') {
|
||||
throw new Exception('Invalid signature timestamp token');
|
||||
}
|
||||
|
||||
return $this->asn1->encodeContext(1, $this->attribute(self::OID_SIGNATURE_TIMESTAMP, $token));
|
||||
}
|
||||
|
||||
/**
|
||||
* Encode a single Attribute (type plus a one-element value SET).
|
||||
*
|
||||
* @throws Exception If encoding fails.
|
||||
*/
|
||||
private function attribute(string $oid, string $value): string
|
||||
{
|
||||
return $this->asn1->encodeSequence($this->asn1->encodeObjectIdentifier($oid) . $this->asn1->encodeSet($value));
|
||||
}
|
||||
|
||||
/**
|
||||
* Encode the SigningCertificateV2 attribute value.
|
||||
*
|
||||
* The ESSCertIDv2 hashAlgorithm defaults to SHA-256, so it is omitted when
|
||||
* the digest is SHA-256 and included otherwise (DER default handling).
|
||||
*
|
||||
* @throws Exception If encoding fails.
|
||||
*/
|
||||
private function signingCertificateV2(string $certHash, string $digestAlgorithm, string $digestOid): string
|
||||
{
|
||||
$essCertId = '';
|
||||
if ($digestAlgorithm !== 'sha256') {
|
||||
$essCertId .= $this->algorithmIdentifier($digestOid, false);
|
||||
}
|
||||
|
||||
$essCertId .= $this->asn1->encodeOctetString($certHash);
|
||||
|
||||
return $this->asn1->encodeSequence($this->asn1->encodeSequence($this->asn1->encodeSequence($essCertId)));
|
||||
}
|
||||
|
||||
/**
|
||||
* Encode an AlgorithmIdentifier, with optional NULL parameters.
|
||||
*
|
||||
* @throws Exception If encoding fails.
|
||||
*/
|
||||
private function algorithmIdentifier(string $oid, bool $nullParameters): string
|
||||
{
|
||||
$parameters = $nullParameters ? $this->asn1->encodeNull() : '';
|
||||
return $this->asn1->encodeSequence($this->asn1->encodeObjectIdentifier($oid) . $parameters);
|
||||
}
|
||||
|
||||
/**
|
||||
* Encode the signing-time value as UTCTime (1950-2049) or GeneralizedTime.
|
||||
*
|
||||
* @throws Exception If encoding fails.
|
||||
*/
|
||||
private function encodeTime(int $signingTime): string
|
||||
{
|
||||
$year = (int) \gmdate('Y', $signingTime);
|
||||
if ($year >= 1950 && $year < 2050) {
|
||||
$value = \gmdate('ymdHis', $signingTime) . 'Z';
|
||||
return "\x17" . $this->asn1->encodeLength(\strlen($value)) . $value;
|
||||
}
|
||||
|
||||
$value = \gmdate('YmdHis', $signingTime) . 'Z';
|
||||
return "\x18" . $this->asn1->encodeLength(\strlen($value)) . $value;
|
||||
}
|
||||
|
||||
/**
|
||||
* Build the IssuerAndSerialNumber from the signer certificate.
|
||||
*
|
||||
* @throws Exception If the certificate cannot be parsed.
|
||||
*/
|
||||
private function issuerAndSerialNumber(string $certDer): string
|
||||
{
|
||||
$certOff = 0;
|
||||
$certTlv = $this->asn1->readTlv($certDer, $certOff);
|
||||
$tbsOff = 0;
|
||||
$tbsTlv = $this->asn1->readTlv($certTlv['value'], $tbsOff);
|
||||
$tbs = $tbsTlv['value'];
|
||||
|
||||
$off = 0;
|
||||
if ($off < \strlen($tbs) && (\ord($tbs[$off]) & 0xE0) === 0xA0) {
|
||||
$this->asn1->readTlv($tbs, $off); // version [0]
|
||||
}
|
||||
|
||||
$serial = $this->asn1->readTlv($tbs, $off); // serialNumber
|
||||
$this->asn1->readTlv($tbs, $off); // signature AlgorithmIdentifier
|
||||
$issuer = $this->asn1->readTlv($tbs, $off); // issuer Name
|
||||
|
||||
return $this->asn1->encodeSequence($issuer['raw'] . $serial['raw']);
|
||||
}
|
||||
}
|
||||
+174
@@ -0,0 +1,174 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
/**
|
||||
* Config.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;
|
||||
|
||||
/**
|
||||
* Com\Tecnick\Pdf\Sign\Config
|
||||
*
|
||||
* Immutable signature configuration value object. Captures the signing profile,
|
||||
* digest algorithm, and certification level, and derives the PDF /SubFilter
|
||||
* from the selected profile.
|
||||
*
|
||||
* @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 Config
|
||||
{
|
||||
/**
|
||||
* Legacy ISO 32000-1 signature (/SubFilter /adbe.pkcs7.detached).
|
||||
*/
|
||||
public const PROFILE_LEGACY = 'legacy';
|
||||
|
||||
/**
|
||||
* PAdES baseline B-B (CAdES-based, /SubFilter /ETSI.CAdES.detached).
|
||||
*/
|
||||
public const PROFILE_PADES_B_B = 'pades-b-b';
|
||||
|
||||
/**
|
||||
* PAdES baseline B-T (B-B plus a signature timestamp).
|
||||
*/
|
||||
public const PROFILE_PADES_B_T = 'pades-b-t';
|
||||
|
||||
/**
|
||||
* PAdES baseline B-LT (B-T plus a Document Security Store).
|
||||
*/
|
||||
public const PROFILE_PADES_B_LT = 'pades-b-lt';
|
||||
|
||||
/**
|
||||
* PAdES baseline B-LTA (B-LT plus a document timestamp).
|
||||
*/
|
||||
public const PROFILE_PADES_B_LTA = 'pades-b-lta';
|
||||
|
||||
/**
|
||||
* Supported signature profiles.
|
||||
*
|
||||
* @var list<string>
|
||||
*/
|
||||
public const PROFILES = [
|
||||
self::PROFILE_LEGACY,
|
||||
self::PROFILE_PADES_B_B,
|
||||
self::PROFILE_PADES_B_T,
|
||||
self::PROFILE_PADES_B_LT,
|
||||
self::PROFILE_PADES_B_LTA,
|
||||
];
|
||||
|
||||
/**
|
||||
* Supported CMS digest algorithms.
|
||||
*
|
||||
* @var list<string>
|
||||
*/
|
||||
public const DIGEST_ALGORITHMS = ['sha256', 'sha384', 'sha512'];
|
||||
|
||||
/**
|
||||
* Selected signature profile (one of the PROFILE_* constants).
|
||||
*/
|
||||
public readonly string $profile;
|
||||
|
||||
/**
|
||||
* Selected CMS digest algorithm (one of the DIGEST_ALGORITHMS values).
|
||||
*/
|
||||
public readonly string $digestAlgorithm;
|
||||
|
||||
/**
|
||||
* @param string|SignatureProfile $profile Profile identifier or enum case.
|
||||
* @param string|DigestAlgorithm $digestAlgorithm Digest algorithm name or enum case.
|
||||
* @param int $certType Certification level (DocMDP P value):
|
||||
* 0 = approval/UR signature,
|
||||
* 1 = no changes permitted,
|
||||
* 2 = form fill-in and signing permitted,
|
||||
* 3 = as 2 plus annotation changes.
|
||||
*
|
||||
* @throws Exception If any option is invalid.
|
||||
*/
|
||||
public function __construct(
|
||||
string|SignatureProfile $profile = self::PROFILE_LEGACY,
|
||||
string|DigestAlgorithm $digestAlgorithm = 'sha256',
|
||||
public readonly int $certType = 2,
|
||||
) {
|
||||
$profile = $profile instanceof SignatureProfile ? $profile->value : $profile;
|
||||
$digestAlgorithm = $digestAlgorithm instanceof DigestAlgorithm ? $digestAlgorithm->value : $digestAlgorithm;
|
||||
|
||||
if (!\in_array($profile, self::PROFILES, true)) {
|
||||
throw new Exception('Invalid signature profile: ' . $profile);
|
||||
}
|
||||
|
||||
if (!\in_array($digestAlgorithm, self::DIGEST_ALGORITHMS, true)) {
|
||||
throw new Exception('Invalid digest algorithm: ' . $digestAlgorithm);
|
||||
}
|
||||
|
||||
if ($certType < 0 || $certType > 3) {
|
||||
throw new Exception('Invalid certification level (cert_type): ' . $certType);
|
||||
}
|
||||
|
||||
$this->profile = $profile;
|
||||
$this->digestAlgorithm = $digestAlgorithm;
|
||||
}
|
||||
|
||||
/**
|
||||
* True when a PAdES profile is selected.
|
||||
*/
|
||||
public function isPades(): bool
|
||||
{
|
||||
return $this->profile !== self::PROFILE_LEGACY;
|
||||
}
|
||||
|
||||
/**
|
||||
* PDF /SubFilter value for the selected profile.
|
||||
*/
|
||||
public function subFilter(): string
|
||||
{
|
||||
return $this->isPades() ? 'ETSI.CAdES.detached' : 'adbe.pkcs7.detached';
|
||||
}
|
||||
|
||||
/**
|
||||
* Build a Config from the legacy associative-array shape used by
|
||||
* Tcpdf::setSignature(), for backward compatibility.
|
||||
*
|
||||
* @param array<string, mixed> $data Signature options.
|
||||
*
|
||||
* @throws Exception If any option is present but of the wrong type or value.
|
||||
*/
|
||||
public static function fromArray(array $data): self
|
||||
{
|
||||
/** @var mixed $profile */
|
||||
$profile = $data['profile'] ?? self::PROFILE_LEGACY;
|
||||
if (!\is_string($profile) && !$profile instanceof SignatureProfile) {
|
||||
throw new Exception('Invalid signature profile');
|
||||
}
|
||||
|
||||
/** @var mixed $digest */
|
||||
$digest = $data['digest_algorithm'] ?? 'sha256';
|
||||
if (!\is_string($digest) && !$digest instanceof DigestAlgorithm) {
|
||||
throw new Exception('Invalid digest algorithm');
|
||||
}
|
||||
|
||||
/** @var mixed $certType */
|
||||
$certType = $data['cert_type'] ?? 2;
|
||||
if (!\is_int($certType)) {
|
||||
throw new Exception('Invalid certification level (cert_type)');
|
||||
}
|
||||
|
||||
return new self($profile, $digest, $certType);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,64 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
/**
|
||||
* DigestAlgorithm.php
|
||||
*
|
||||
* @since 2026-07-17
|
||||
* @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;
|
||||
|
||||
/**
|
||||
* Com\Tecnick\Pdf\Sign\DigestAlgorithm
|
||||
*
|
||||
* Backed enum for the supported message-digest algorithms. Unifies the two
|
||||
* previously identical closed sets: Config::DIGEST_ALGORITHMS (CMS builder) and
|
||||
* Timestamp\Config::HASH_ALGORITHMS (RFC 3161 message imprint). The backing
|
||||
* value is the lowercase algorithm name accepted by both.
|
||||
*
|
||||
* @since 2026-07-17
|
||||
* @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
|
||||
*/
|
||||
enum DigestAlgorithm: string
|
||||
{
|
||||
case Sha256 = 'sha256';
|
||||
|
||||
case Sha384 = 'sha384';
|
||||
|
||||
case Sha512 = 'sha512';
|
||||
|
||||
/**
|
||||
* Resolve a loose digest algorithm value to the matching enum case.
|
||||
*
|
||||
* Accepts the canonical algorithm string (as validated by Config and
|
||||
* Timestamp\Config) or an enum instance (returned unchanged). Unknown values
|
||||
* throw, matching the closed set enforced by both configs.
|
||||
*
|
||||
* @param string|self $value Digest algorithm name or enum case.
|
||||
*
|
||||
* @throws Exception if the value does not match a known digest algorithm.
|
||||
*/
|
||||
public static function fromLoose(string|self $value): self
|
||||
{
|
||||
if ($value instanceof self) {
|
||||
return $value;
|
||||
}
|
||||
|
||||
return self::tryFrom($value) ?? throw new Exception('Invalid digest algorithm: ' . $value);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,34 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
/**
|
||||
* Exception.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;
|
||||
|
||||
/**
|
||||
* Com\Tecnick\Pdf\Sign\Exception
|
||||
*
|
||||
* Custom Exception class for the PDF signature library.
|
||||
*
|
||||
* @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
|
||||
*/
|
||||
class Exception extends \Exception {}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,187 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
/**
|
||||
* Client.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\Ocsp;
|
||||
|
||||
use Com\Tecnick\Pdf\Sign\Cms\Asn1;
|
||||
use Com\Tecnick\Pdf\Sign\Exception;
|
||||
|
||||
/**
|
||||
* Com\Tecnick\Pdf\Sign\Ocsp\Client
|
||||
*
|
||||
* RFC 6960 OCSP request builder. Extracts the subject Name and public key from
|
||||
* the issuer certificate and the serial number from the target certificate,
|
||||
* then assembles an OCSPRequest with a SHA-1 CertID. HTTP transport is injected
|
||||
* into fetch() so the codec stays pure and testable while the host controls
|
||||
* networking (and SSRF protection).
|
||||
*
|
||||
* @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 Client
|
||||
{
|
||||
private Asn1 $asn1;
|
||||
|
||||
public function __construct(?Asn1 $asn1 = null)
|
||||
{
|
||||
$this->asn1 = $asn1 ?? new Asn1();
|
||||
}
|
||||
|
||||
/**
|
||||
* Build a DER-encoded RFC 6960 OCSPRequest for a single certificate.
|
||||
*
|
||||
* @param string $issuerDer DER of the issuing certificate.
|
||||
* @param string $leafDer DER of the certificate whose status is queried.
|
||||
*
|
||||
* @throws Exception If either certificate cannot be parsed or encoded.
|
||||
*/
|
||||
public function build(string $issuerDer, string $leafDer): string
|
||||
{
|
||||
$issuer = $this->extractSubjectAndPublicKey($issuerDer);
|
||||
$issuerNameHash = \hash('sha1', $issuer['subject'], true);
|
||||
$issuerKeyHash = \hash('sha1', $issuer['public_key'], true);
|
||||
$serial = $this->extractSerialNumber($leafDer);
|
||||
|
||||
$algId = $this->asn1->encodeSequence(
|
||||
$this->asn1->encodeObjectIdentifier('1.3.14.3.2.26') . $this->asn1->encodeNull(),
|
||||
);
|
||||
$certId = $this->asn1->encodeSequence(
|
||||
$algId . $this->asn1->encodeOctetString($issuerNameHash) . $this->asn1->encodeOctetString($issuerKeyHash)
|
||||
. $this->asn1->encodeIntegerBytes($serial),
|
||||
);
|
||||
$requestList = $this->asn1->encodeSequence($this->asn1->encodeSequence($certId));
|
||||
|
||||
return $this->asn1->encodeSequence($this->asn1->encodeSequence($requestList));
|
||||
}
|
||||
|
||||
/**
|
||||
* Build the request and submit it through the given transport.
|
||||
*
|
||||
* @param string $url OCSP responder URL.
|
||||
* @param string $issuerDer DER of the issuing certificate.
|
||||
* @param string $leafDer DER of the target certificate.
|
||||
* @param callable $transport Receives (url, DER request) and must return the
|
||||
* DER response string.
|
||||
*
|
||||
* @throws Exception If building, transport, or the response type fails.
|
||||
*/
|
||||
public function fetch(string $url, string $issuerDer, string $leafDer, callable $transport): string
|
||||
{
|
||||
$request = $this->build($issuerDer, $leafDer);
|
||||
|
||||
/** @var mixed $response */
|
||||
$response = $transport($url, $request);
|
||||
if (!\is_string($response)) {
|
||||
throw new Exception('Invalid OCSP transport response');
|
||||
}
|
||||
|
||||
return $response;
|
||||
}
|
||||
|
||||
/**
|
||||
* Extract the raw DER of the subject Name and the public-key bytes from a
|
||||
* DER-encoded X.509 certificate.
|
||||
*
|
||||
* The subject bytes are the full DER of the subject Name SEQUENCE. The
|
||||
* public-key bytes are the subjectPublicKey BIT STRING value without the
|
||||
* leading unused-bits octet. For an OCSP CertID the issuerNameHash and
|
||||
* issuerKeyHash are computed over the SUBJECT of the issuing certificate,
|
||||
* so this reads the subject field (not the issuer field).
|
||||
*
|
||||
* @return array{subject: string, public_key: string}
|
||||
*
|
||||
* @throws Exception If the certificate cannot be parsed.
|
||||
*/
|
||||
public function extractSubjectAndPublicKey(string $certDer): array
|
||||
{
|
||||
$tbs = $this->tbsCertificate($certDer);
|
||||
|
||||
$off = 0;
|
||||
$this->skipOptionalVersion($tbs, $off);
|
||||
$this->asn1->readTlv($tbs, $off); // serialNumber
|
||||
$this->asn1->readTlv($tbs, $off); // signature AlgorithmIdentifier
|
||||
$this->asn1->readTlv($tbs, $off); // issuer Name
|
||||
$this->asn1->readTlv($tbs, $off); // validity
|
||||
|
||||
$subjectStart = $off;
|
||||
$this->asn1->readTlv($tbs, $off); // subject Name
|
||||
$subjectDer = \substr($tbs, $subjectStart, $off - $subjectStart);
|
||||
|
||||
$spki = $this->asn1->readTlv($tbs, $off); // subjectPublicKeyInfo
|
||||
$spkiOff = 0;
|
||||
$this->asn1->readTlv($spki['value'], $spkiOff); // algorithm
|
||||
$bitStr = $this->asn1->readTlv($spki['value'], $spkiOff); // subjectPublicKey BIT STRING
|
||||
|
||||
return [
|
||||
'subject' => $subjectDer,
|
||||
'public_key' => \substr($bitStr['value'], 1),
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* Extract the raw serialNumber INTEGER content octets from a DER-encoded
|
||||
* X.509 certificate.
|
||||
*
|
||||
* @throws Exception If the certificate cannot be parsed.
|
||||
*/
|
||||
public function extractSerialNumber(string $certDer): string
|
||||
{
|
||||
$tbs = $this->tbsCertificate($certDer);
|
||||
|
||||
$off = 0;
|
||||
$this->skipOptionalVersion($tbs, $off);
|
||||
$serial = $this->asn1->readTlv($tbs, $off); // serialNumber
|
||||
|
||||
return $serial['value'];
|
||||
}
|
||||
|
||||
/**
|
||||
* Return the TBSCertificate content octets of a DER-encoded certificate.
|
||||
*
|
||||
* @throws Exception If the certificate cannot be parsed.
|
||||
*/
|
||||
private function tbsCertificate(string $certDer): string
|
||||
{
|
||||
$certOff = 0;
|
||||
$certTlv = $this->asn1->readTlv($certDer, $certOff);
|
||||
|
||||
$tbsOff = 0;
|
||||
$tbsTlv = $this->asn1->readTlv($certTlv['value'], $tbsOff);
|
||||
|
||||
return $tbsTlv['value'];
|
||||
}
|
||||
|
||||
/**
|
||||
* Skip the optional [0] EXPLICIT version field if present.
|
||||
*
|
||||
* @param int $off Read cursor; advanced past the version when present.
|
||||
*
|
||||
* @throws Exception If the version field is malformed.
|
||||
*/
|
||||
private function skipOptionalVersion(string $tbs, int &$off): void
|
||||
{
|
||||
if ($off < \strlen($tbs) && (\ord($tbs[$off]) & 0xE0) === 0xA0) {
|
||||
$this->asn1->readTlv($tbs, $off);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,60 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
/**
|
||||
* DocTimeStamp.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\Output;
|
||||
|
||||
/**
|
||||
* Com\Tecnick\Pdf\Sign\Output\DocTimeStamp
|
||||
*
|
||||
* Emits a document timestamp value object (/Type /DocTimeStamp,
|
||||
* /SubFilter /ETSI.RFC3161) whose /Contents is a bare RFC 3161 timestamp token.
|
||||
* It is added in an incremental update to reach PAdES B-LTA. It shares the
|
||||
* /ByteRange and /Contents placeholders with the signature value object so the
|
||||
* host's signing pass locates them the same way for either object type.
|
||||
*
|
||||
* @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 DocTimeStamp
|
||||
{
|
||||
/**
|
||||
* SubFilter for an RFC 3161 document timestamp.
|
||||
*/
|
||||
public const SUB_FILTER = 'ETSI.RFC3161';
|
||||
|
||||
/**
|
||||
* Emit the /DocTimeStamp value object.
|
||||
*
|
||||
* @param int $objectId Object number for the value object.
|
||||
* @param int $contentsLength Placeholder length reserved for the token.
|
||||
*/
|
||||
public function valueObject(int $objectId, int $contentsLength = Signature::DEFAULT_CONTENTS_LENGTH): string
|
||||
{
|
||||
$out = $objectId . " 0 obj\n";
|
||||
$out .= '<< /Type /DocTimeStamp /Filter /Adobe.PPKLite /SubFilter /' . self::SUB_FILTER . ' ';
|
||||
$out .= Signature::BYTE_RANGE_PLACEHOLDER;
|
||||
$out .= ' /Contents<' . \str_repeat('0', \max(0, $contentsLength)) . '>';
|
||||
|
||||
return $out . " >>\nendobj\n";
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,183 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
/**
|
||||
* Dss.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\Output;
|
||||
|
||||
use Com\Tecnick\Pdf\Sign\Exception;
|
||||
|
||||
/**
|
||||
* Com\Tecnick\Pdf\Sign\Output\Dss
|
||||
*
|
||||
* Emits the Document Security Store (DSS) PDF objects for a single signature:
|
||||
* the certificate, OCSP, and CRL streams, a VRI entry, and the DSS dictionary.
|
||||
* The object number is passed by reference and advanced, and the concatenated
|
||||
* object bytes are returned. Stream encryption is delegated to an optional
|
||||
* encryptor callable so the emitter does not depend on the host encryption object.
|
||||
*
|
||||
* The VRI key is the uppercase base-16 SHA-1 digest of the signature Contents
|
||||
* bytes, per ISO 32000-2 clause 12.8.4.3.
|
||||
*
|
||||
* @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 Dss
|
||||
{
|
||||
/**
|
||||
* Emit the DSS objects for a signature's validation material.
|
||||
*
|
||||
* @param array{certs: list<string>, ocsp: list<string>, crls: list<string>} $material
|
||||
* @param string $signatureContents Signature /Contents bytes (hex-decoded,
|
||||
* including any placeholder padding), hashed for the VRI key.
|
||||
* @param int $pon Current object number; advanced by reference.
|
||||
* @param callable|null $encryptor Optional fn(string $data, int $objectId): string.
|
||||
*
|
||||
* @return array{objects: array<int, string>, object_id: int} The emitted object
|
||||
* bodies keyed by object number, and the DSS dictionary object number
|
||||
* (an empty map and 0 when there is no material to emit). The keyed shape
|
||||
* feeds an incremental-update writer directly, one xref entry per object.
|
||||
*
|
||||
* @throws Exception If the encryptor returns a non-string value.
|
||||
*/
|
||||
public function emit(array $material, string $signatureContents, int &$pon, ?callable $encryptor = null): array
|
||||
{
|
||||
if ($material['certs'] === [] && $material['ocsp'] === [] && $material['crls'] === []) {
|
||||
return ['objects' => [], 'object_id' => 0];
|
||||
}
|
||||
|
||||
$objects = [];
|
||||
$certIds = $this->emitStreams($material['certs'], $pon, $objects, $encryptor);
|
||||
$ocspIds = $this->emitStreams($material['ocsp'], $pon, $objects, $encryptor);
|
||||
$crlIds = $this->emitStreams($material['crls'], $pon, $objects, $encryptor);
|
||||
|
||||
$vriKey = \strtoupper(\sha1($signatureContents));
|
||||
$vriObjectId = ++$pon;
|
||||
$objects[$vriObjectId] = $this->vriObject($vriObjectId, $certIds, $ocspIds, $crlIds);
|
||||
|
||||
$dssObjectId = ++$pon;
|
||||
$objects[$dssObjectId] = $this->dssObject($dssObjectId, $vriKey, $vriObjectId, $certIds, $ocspIds, $crlIds);
|
||||
|
||||
return ['objects' => $objects, 'object_id' => $dssObjectId];
|
||||
}
|
||||
|
||||
/**
|
||||
* Emit one stream object per payload and return the assigned object numbers.
|
||||
*
|
||||
* @param list<string> $items
|
||||
* @param array<int, string> $objects Emitted object bodies keyed by number; appended to.
|
||||
*
|
||||
* @return list<int>
|
||||
*
|
||||
* @throws Exception If the encryptor returns a non-string value.
|
||||
*/
|
||||
private function emitStreams(array $items, int &$pon, array &$objects, ?callable $encryptor): array
|
||||
{
|
||||
$ids = [];
|
||||
foreach ($items as $item) {
|
||||
$objectId = ++$pon;
|
||||
$ids[] = $objectId;
|
||||
$stream = $encryptor !== null ? $this->encryptStream($encryptor, $item, $objectId) : $item;
|
||||
$objects[$objectId] =
|
||||
$objectId
|
||||
. " 0 obj\n"
|
||||
. '<< /Length '
|
||||
. \strlen($stream)
|
||||
. " >>\n"
|
||||
. "stream\n"
|
||||
. $stream
|
||||
. "\nendstream\n"
|
||||
. "endobj\n";
|
||||
}
|
||||
|
||||
return $ids;
|
||||
}
|
||||
|
||||
/**
|
||||
* @throws Exception If the encryptor returns a non-string value.
|
||||
*/
|
||||
private function encryptStream(callable $encryptor, string $data, int $objectId): string
|
||||
{
|
||||
/** @var mixed $result */
|
||||
$result = $encryptor($data, $objectId);
|
||||
if (!\is_string($result)) {
|
||||
throw new Exception('Invalid stream encryptor result');
|
||||
}
|
||||
|
||||
return $result;
|
||||
}
|
||||
|
||||
/**
|
||||
* @param list<int> $certIds
|
||||
* @param list<int> $ocspIds
|
||||
* @param list<int> $crlIds
|
||||
*/
|
||||
private function vriObject(int $objectId, array $certIds, array $ocspIds, array $crlIds): string
|
||||
{
|
||||
$out = $objectId . " 0 obj\n" . '<< /Type /VRI';
|
||||
$out .= $this->referenceArray('Cert', $certIds);
|
||||
$out .= $this->referenceArray('OCSP', $ocspIds);
|
||||
$out .= $this->referenceArray('CRL', $crlIds);
|
||||
|
||||
return $out . " >>\nendobj\n";
|
||||
}
|
||||
|
||||
/**
|
||||
* @param list<int> $certIds
|
||||
* @param list<int> $ocspIds
|
||||
* @param list<int> $crlIds
|
||||
*/
|
||||
private function dssObject(
|
||||
int $objectId,
|
||||
string $vriKey,
|
||||
int $vriObjectId,
|
||||
array $certIds,
|
||||
array $ocspIds,
|
||||
array $crlIds,
|
||||
): string {
|
||||
$out = $objectId . " 0 obj\n" . '<< /Type /DSS';
|
||||
$out .= ' /VRI << /' . $vriKey . ' ' . $vriObjectId . ' 0 R >>';
|
||||
$out .= $this->referenceArray('Certs', $certIds);
|
||||
$out .= $this->referenceArray('OCSPs', $ocspIds);
|
||||
$out .= $this->referenceArray('CRLs', $crlIds);
|
||||
|
||||
return $out . " >>\nendobj\n";
|
||||
}
|
||||
|
||||
/**
|
||||
* Render a named array of indirect references, or the empty string.
|
||||
*
|
||||
* @param list<int> $ids
|
||||
*/
|
||||
private function referenceArray(string $name, array $ids): string
|
||||
{
|
||||
if ($ids === []) {
|
||||
return '';
|
||||
}
|
||||
|
||||
$refs = '';
|
||||
foreach ($ids as $id) {
|
||||
$refs .= ' ' . $id . ' 0 R';
|
||||
}
|
||||
|
||||
return ' /' . $name . ' [' . $refs . ' ]';
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,61 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
/**
|
||||
* PdfString.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\Output;
|
||||
|
||||
use Com\Tecnick\Pdf\Sign\Exception;
|
||||
|
||||
/**
|
||||
* Com\Tecnick\Pdf\Sign\Output\PdfString
|
||||
*
|
||||
* Encodes a text value as a PDF string token, either through a host-supplied
|
||||
* encoder (which may apply UTF-16, escaping, and encryption) or, when none is
|
||||
* given, a minimal literal-string fallback for ASCII content.
|
||||
*
|
||||
* @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 PdfString
|
||||
{
|
||||
/**
|
||||
* Encode a text value as a PDF string token.
|
||||
*
|
||||
* @param callable|null $encoder fn(string $text, int $objectId): string
|
||||
*
|
||||
* @throws Exception If the encoder returns a non-string value.
|
||||
*/
|
||||
public static function encode(string $text, int $objectId, ?callable $encoder = null): string
|
||||
{
|
||||
if ($encoder === null) {
|
||||
return '(' . \strtr($text, ['\\' => '\\\\', '(' => '\\(', ')' => '\\)']) . ')';
|
||||
}
|
||||
|
||||
/** @var mixed $result */
|
||||
$result = $encoder($text, $objectId);
|
||||
if (!\is_string($result)) {
|
||||
throw new Exception('Invalid string encoder result');
|
||||
}
|
||||
|
||||
return $result;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,103 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
/**
|
||||
* Signature.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\Output;
|
||||
|
||||
use Com\Tecnick\Pdf\Sign\Exception;
|
||||
|
||||
/**
|
||||
* Com\Tecnick\Pdf\Sign\Output\Signature
|
||||
*
|
||||
* Emits the /Sig value dictionary (the object referenced by a signature field's
|
||||
* /V): the fixed skeleton, the /SubFilter, and the /ByteRange and /Contents
|
||||
* placeholders that the host rewrites while signing, plus the optional
|
||||
* Name/Location/Reason/ContactInfo strings.
|
||||
*
|
||||
* The /Reference (DocMDP or UR3 transform) and the /M date token are supplied by
|
||||
* the caller as ready fragments, because their content and formatting depend on
|
||||
* host state (certification level, user rights, timezone, encryption). This keeps
|
||||
* the byte skeleton and the signing-critical placeholders in one place while
|
||||
* letting the host own the semantic parts. String encoding (escaping, UTF-16,
|
||||
* encryption) of the info values is delegated to an injected encoder.
|
||||
*
|
||||
* @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 Signature
|
||||
{
|
||||
/**
|
||||
* ByteRange placeholder rewritten by the host once the byte offsets are known.
|
||||
*/
|
||||
public const BYTE_RANGE_PLACEHOLDER = '/ByteRange[0 ********** ********** **********]';
|
||||
|
||||
/**
|
||||
* Default number of hex zero placeholder characters reserved for /Contents.
|
||||
*/
|
||||
public const DEFAULT_CONTENTS_LENGTH = 11_742;
|
||||
|
||||
/**
|
||||
* Info string entries, in output order.
|
||||
*
|
||||
* @var list<string>
|
||||
*/
|
||||
private const INFO_KEYS = ['Name', 'Location', 'Reason', 'ContactInfo'];
|
||||
|
||||
/**
|
||||
* Emit the /Sig value object.
|
||||
*
|
||||
* @param int $objectId Object number for the /Sig value object.
|
||||
* @param string $subFilter e.g. "ETSI.CAdES.detached" or "adbe.pkcs7.detached".
|
||||
* @param string $reference Ready /Reference fragment (DocMDP or UR3 transform),
|
||||
* leading space included, or '' for an approval signature.
|
||||
* @param array<string, string> $info Optional Name/Location/Reason/ContactInfo.
|
||||
* @param string $dateValue Ready (already encoded) PDF string token for /M.
|
||||
* @param int $contentsLength Placeholder length for /Contents.
|
||||
* @param callable|null $stringEncoder fn(string $text, int $objectId): string returning a PDF string token.
|
||||
*
|
||||
* @throws Exception If the string encoder returns a non-string value.
|
||||
*/
|
||||
public function valueObject(
|
||||
int $objectId,
|
||||
string $subFilter,
|
||||
string $reference,
|
||||
array $info,
|
||||
string $dateValue,
|
||||
int $contentsLength = self::DEFAULT_CONTENTS_LENGTH,
|
||||
?callable $stringEncoder = null,
|
||||
): string {
|
||||
$out = $objectId . " 0 obj\n";
|
||||
$out .= '<< /Type /Sig /Filter /Adobe.PPKLite /SubFilter /' . $subFilter . ' ';
|
||||
$out .= self::BYTE_RANGE_PLACEHOLDER;
|
||||
$out .= ' /Contents<' . \str_repeat('0', \max(0, $contentsLength)) . '>';
|
||||
$out .= $reference;
|
||||
|
||||
foreach (self::INFO_KEYS as $key) {
|
||||
$value = $info[$key] ?? '';
|
||||
if ($value !== '') {
|
||||
$out .= ' /' . $key . ' ' . PdfString::encode($value, $objectId, $stringEncoder);
|
||||
}
|
||||
}
|
||||
|
||||
return $out . ' /M ' . $dateValue . " >>\nendobj\n";
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,79 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
/**
|
||||
* Widget.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\Output;
|
||||
|
||||
use Com\Tecnick\Pdf\Sign\Exception;
|
||||
|
||||
/**
|
||||
* Com\Tecnick\Pdf\Sign\Output\Widget
|
||||
*
|
||||
* Emits a signature field's widget annotation (/Subtype /Widget, /FT /Sig). The
|
||||
* same shape serves the signed field (with a /V reference to the /Sig value
|
||||
* object) and the reserved empty approval fields (no /V). The rectangle, the
|
||||
* page object number, and any appearance fragment are computed by the host,
|
||||
* which knows the page geometry and appearance resources.
|
||||
*
|
||||
* @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 Widget
|
||||
{
|
||||
/**
|
||||
* Emit a signature widget annotation object.
|
||||
*
|
||||
* @param int $objectId Annotation object number.
|
||||
* @param string $rect Rectangle coordinates "x0 y0 x1 y1".
|
||||
* @param int $pageObjectId Object number of the page the widget is on (/P).
|
||||
* @param string $fieldName Partial field name (/T).
|
||||
* @param int|null $valueObjectId /V value object number; null for an unsigned field.
|
||||
* @param string $appearance Optional pre-built appearance fragment (e.g. " /AS /N /AP << ... >>").
|
||||
* @param callable|null $stringEncoder fn(string $text, int $objectId): string.
|
||||
*
|
||||
* @throws Exception If the string encoder returns a non-string value.
|
||||
*/
|
||||
public function annotation(
|
||||
int $objectId,
|
||||
string $rect,
|
||||
int $pageObjectId,
|
||||
string $fieldName,
|
||||
?int $valueObjectId = null,
|
||||
string $appearance = '',
|
||||
?callable $stringEncoder = null,
|
||||
): string {
|
||||
$out = $objectId . " 0 obj\n";
|
||||
$out .= '<< /Type /Annot /Subtype /Widget';
|
||||
$out .= ' /Rect [' . $rect . ']';
|
||||
$out .= ' /P ' . $pageObjectId . ' 0 R';
|
||||
$out .= ' /F 4 /FT /Sig';
|
||||
$out .= ' /T ' . PdfString::encode($fieldName, $objectId, $stringEncoder);
|
||||
$out .= ' /Ff 0';
|
||||
$out .= $appearance;
|
||||
|
||||
if ($valueObjectId !== null) {
|
||||
$out .= ' /V ' . $valueObjectId . ' 0 R';
|
||||
}
|
||||
|
||||
return $out . " >>\nendobj\n";
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,66 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
/**
|
||||
* SignatureProfile.php
|
||||
*
|
||||
* @since 2026-07-17
|
||||
* @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;
|
||||
|
||||
/**
|
||||
* Com\Tecnick\Pdf\Sign\SignatureProfile
|
||||
*
|
||||
* Backed enum for the supported signature profiles. The backing value of each
|
||||
* case matches the corresponding Config::PROFILE_* constant validated by Config.
|
||||
*
|
||||
* @since 2026-07-17
|
||||
* @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
|
||||
*/
|
||||
enum SignatureProfile: string
|
||||
{
|
||||
case Legacy = 'legacy';
|
||||
|
||||
case PadesBB = 'pades-b-b';
|
||||
|
||||
case PadesBT = 'pades-b-t';
|
||||
|
||||
case PadesBLT = 'pades-b-lt';
|
||||
|
||||
case PadesBLTA = 'pades-b-lta';
|
||||
|
||||
/**
|
||||
* Resolve a loose signature profile value to the matching enum case.
|
||||
*
|
||||
* Accepts the canonical profile string (as validated by Config) or an enum
|
||||
* instance (returned unchanged). Unknown values throw, matching the closed
|
||||
* set enforced by Config.
|
||||
*
|
||||
* @param string|self $value Signature profile identifier or enum case.
|
||||
*
|
||||
* @throws Exception if the value does not match a known signature profile.
|
||||
*/
|
||||
public static function fromLoose(string|self $value): self
|
||||
{
|
||||
if ($value instanceof self) {
|
||||
return $value;
|
||||
}
|
||||
|
||||
return self::tryFrom($value) ?? throw new Exception('Invalid signature profile: ' . $value);
|
||||
}
|
||||
}
|
||||
+226
@@ -0,0 +1,226 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
/**
|
||||
* Signer.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;
|
||||
|
||||
use Com\Tecnick\Pdf\Sign\Cms\Builder;
|
||||
use Com\Tecnick\Pdf\Sign\Ltv\ValidationMaterial;
|
||||
use Com\Tecnick\Pdf\Sign\Timestamp\Client as TimestampClient;
|
||||
use OpenSSLAsymmetricKey;
|
||||
|
||||
/**
|
||||
* Com\Tecnick\Pdf\Sign\Signer
|
||||
*
|
||||
* Package-internal orchestration entry point that ties the CMS builder, the RFC
|
||||
* 3161 timestamp codec, and the LTV material collector together behind two
|
||||
* host-facing calls. It stays transport-injected and free of file and network
|
||||
* access: the host loads keys and owns HTTP (and SSRF protection).
|
||||
*
|
||||
* sign() produces the detached CAdES CMS for a document's ByteRange bytes. For a
|
||||
* legacy or PAdES B-B profile that is the plain CMS; for B-T and above it also
|
||||
* requests an RFC 3161 signature timestamp and embeds it as the SignerInfo
|
||||
* id-aa-signatureTimeStampToken unsigned attribute.
|
||||
*
|
||||
* collectValidationMaterial() gathers the certificates, OCSP responses, and CRLs
|
||||
* a B-LT or B-LTA document needs, shaped for the DSS emitter. The VRI key is not
|
||||
* computed here: it depends on the final signature Contents and belongs to the
|
||||
* DSS writer.
|
||||
*
|
||||
* @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 Signer
|
||||
{
|
||||
/**
|
||||
* Profiles that require an embedded signature timestamp (B-T and above).
|
||||
*
|
||||
* @var list<string>
|
||||
*/
|
||||
private const TIMESTAMPED_PROFILES = [
|
||||
Config::PROFILE_PADES_B_T,
|
||||
Config::PROFILE_PADES_B_LT,
|
||||
Config::PROFILE_PADES_B_LTA,
|
||||
];
|
||||
|
||||
private Builder $builder;
|
||||
|
||||
private ValidationMaterial $validationMaterial;
|
||||
|
||||
public function __construct(?Builder $builder = null, ?ValidationMaterial $validationMaterial = null)
|
||||
{
|
||||
$this->builder = $builder ?? new Builder();
|
||||
$this->validationMaterial = $validationMaterial ?? new ValidationMaterial();
|
||||
}
|
||||
|
||||
/**
|
||||
* Produce the detached CAdES CMS for a document's ByteRange content.
|
||||
*
|
||||
* When the profile is B-T or above, the timestamp client and transport are
|
||||
* required: the RFC 3161 token is requested over the raw signature bytes and
|
||||
* embedded as the id-aa-signatureTimeStampToken unsigned attribute.
|
||||
*
|
||||
* @param string $content ByteRange-covered document bytes to sign.
|
||||
* @param string $signerCertDer DER of the signing certificate.
|
||||
* @param OpenSSLAsymmetricKey $privateKey Signing private key (RSA or EC).
|
||||
* @param list<string> $chainCertsDer Additional certificates (DER) to embed.
|
||||
* @param Config $config Signature profile and digest configuration.
|
||||
* @param int $signingTime Unix timestamp for the signing-time attribute.
|
||||
* @param TimestampClient|null $timestamp RFC 3161 codec; required for B-T and above.
|
||||
* @param (callable(string): string)|null $timestampTransport Maps a DER TimeStampReq to a DER
|
||||
* TimeStampResp; required for B-T and above.
|
||||
*
|
||||
* @return string DER-encoded CMS ContentInfo ready for /Contents injection.
|
||||
*
|
||||
* @throws Exception If a timestamp is required but not configured, or signing fails.
|
||||
*/
|
||||
public function sign(
|
||||
string $content,
|
||||
string $signerCertDer,
|
||||
OpenSSLAsymmetricKey $privateKey,
|
||||
array $chainCertsDer,
|
||||
Config $config,
|
||||
int $signingTime,
|
||||
?TimestampClient $timestamp = null,
|
||||
?callable $timestampTransport = null,
|
||||
): string {
|
||||
$signatureTimestamp = null;
|
||||
if (\in_array($config->profile, self::TIMESTAMPED_PROFILES, true)) {
|
||||
if ($timestamp === null || $timestampTransport === null) {
|
||||
throw new Exception('Profile ' . $config->profile . ' requires a timestamp client and transport');
|
||||
}
|
||||
|
||||
$signatureTimestamp =
|
||||
/** @throws Exception */
|
||||
static fn(string $signature): string => $timestamp->requestToken($signature, $timestampTransport);
|
||||
}
|
||||
|
||||
// PAdES-BASELINE carries the signing time in the /M dictionary entry and forbids
|
||||
// the CMS signing-time attribute; only the legacy profile embeds it.
|
||||
return $this->builder->sign(
|
||||
$content,
|
||||
$signerCertDer,
|
||||
$privateKey,
|
||||
$chainCertsDer,
|
||||
$config->digestAlgorithm,
|
||||
$signingTime,
|
||||
$signatureTimestamp,
|
||||
!$config->isPades(),
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Collect the long-term validation material for an ordered certificate chain.
|
||||
*
|
||||
* The chain must be ordered leaf-first, each entry followed by its issuer.
|
||||
* For every certificate that has an issuer in the chain, OCSP is attempted
|
||||
* against the responder URLs found in its AIA extension; CRLs are attempted
|
||||
* against every certificate's CRL distribution points. A null transport skips
|
||||
* that revocation source. Responses are deduplicated across the whole chain.
|
||||
*
|
||||
* @param list<string> $chainPem Certificates in PEM, leaf first up to the root.
|
||||
* @param (callable(string, string): (string|false))|null $ocspTransport Maps (url, DER request) to
|
||||
* the DER response, or null to skip OCSP.
|
||||
* @param (callable(string): (string|false))|null $crlTransport Maps a url to the CRL bytes, or null
|
||||
* to skip CRLs.
|
||||
*
|
||||
* @return array{certs: list<string>, ocsp: list<string>, crls: list<string>} DSS-ready material.
|
||||
*
|
||||
* @throws Exception If a certificate cannot be parsed or converted.
|
||||
*/
|
||||
public function collectValidationMaterial(
|
||||
array $chainPem,
|
||||
?callable $ocspTransport = null,
|
||||
?callable $crlTransport = null,
|
||||
): array {
|
||||
$certs = [];
|
||||
foreach ($chainPem as $pem) {
|
||||
$certs[] = ['pem' => $pem, 'der' => $this->pemToDer($pem)];
|
||||
}
|
||||
|
||||
$ocsp = [];
|
||||
$crls = [];
|
||||
foreach ($certs as $idx => $cert) {
|
||||
$issuer = $certs[$idx + 1] ?? null;
|
||||
if ($ocspTransport !== null && $issuer !== null) {
|
||||
$urls = $this->validationMaterial->certificateOcspUrls($cert['pem']);
|
||||
$ocsp = [
|
||||
...$ocsp,
|
||||
...$this->validationMaterial->fetchOcsp($issuer['der'], $cert['der'], $urls, $ocspTransport),
|
||||
];
|
||||
}
|
||||
|
||||
if ($crlTransport !== null) {
|
||||
$urls = $this->validationMaterial->certificateCrlUrls($cert['pem']);
|
||||
$crls = [...$crls, ...$this->validationMaterial->fetchCrl($urls, $crlTransport)];
|
||||
}
|
||||
}
|
||||
|
||||
$certDers = \array_map(static fn(array $cert): string => $cert['der'], $certs);
|
||||
|
||||
return [
|
||||
'certs' => $this->deduplicate($certDers),
|
||||
'ocsp' => $this->deduplicate($ocsp),
|
||||
'crls' => $this->deduplicate($crls),
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* Deduplicate a list of binary blobs by content, preserving first-seen order.
|
||||
*
|
||||
* @param list<string> $items
|
||||
*
|
||||
* @return list<string>
|
||||
*/
|
||||
private function deduplicate(array $items): array
|
||||
{
|
||||
$seen = [];
|
||||
$result = [];
|
||||
foreach ($items as $item) {
|
||||
$fingerprint = \hash('sha256', $item);
|
||||
if (isset($seen[$fingerprint])) {
|
||||
continue;
|
||||
}
|
||||
|
||||
$seen[$fingerprint] = true;
|
||||
$result[] = $item;
|
||||
}
|
||||
|
||||
return $result;
|
||||
}
|
||||
|
||||
/**
|
||||
* 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;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,177 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
/**
|
||||
* Client.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\Timestamp;
|
||||
|
||||
use Com\Tecnick\Pdf\Sign\Cms\Asn1;
|
||||
use Com\Tecnick\Pdf\Sign\Exception;
|
||||
|
||||
/**
|
||||
* Com\Tecnick\Pdf\Sign\Timestamp\Client
|
||||
*
|
||||
* RFC 3161 timestamp codec. Builds a TimeStampReq for a signature, parses a
|
||||
* TimeStampResp to extract the timestamp token, and maps digest algorithms to
|
||||
* their OIDs. HTTP transport is intentionally not part of this class: pass a
|
||||
* transport callable to requestToken() so the codec stays pure and testable
|
||||
* while the host controls networking (and SSRF protection).
|
||||
*
|
||||
* @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 Client
|
||||
{
|
||||
private Asn1 $asn1;
|
||||
|
||||
public function __construct(
|
||||
private readonly Config $config,
|
||||
?Asn1 $asn1 = null,
|
||||
) {
|
||||
$this->asn1 = $asn1 ?? new Asn1();
|
||||
}
|
||||
|
||||
/**
|
||||
* Build a DER-encoded RFC 3161 TimeStampReq for the given signature bytes.
|
||||
*
|
||||
* @param string $signature Signature (or any bytes) to be timestamped.
|
||||
*
|
||||
* @throws Exception If encoding fails or a nonce cannot be generated.
|
||||
*/
|
||||
public function buildRequest(string $signature): string
|
||||
{
|
||||
$hashAlgo = $this->config->hashAlgorithm;
|
||||
$hash = \hash($hashAlgo, $signature, true);
|
||||
|
||||
$oid = $this->hashAlgorithmOid($hashAlgo);
|
||||
$messageImprint = $this->asn1->encodeSequence(
|
||||
$this->asn1->encodeSequence($this->asn1->encodeObjectIdentifier($oid) . $this->asn1->encodeNull())
|
||||
. $this->asn1->encodeOctetString($hash),
|
||||
);
|
||||
|
||||
$body = $this->asn1->encodeInteger(1) . $messageImprint;
|
||||
if ($this->config->policyOid !== '') {
|
||||
$body .= $this->asn1->encodeObjectIdentifier($this->config->policyOid);
|
||||
}
|
||||
|
||||
if ($this->config->nonceEnabled) {
|
||||
try {
|
||||
$nonce = \random_int(1, PHP_INT_MAX);
|
||||
} catch (\Random\RandomException $e) {
|
||||
// Defensive: the CSPRNG failing is not reproducible in a unit test.
|
||||
throw new Exception('Unable to generate random nonce: ' . $e->getMessage(), 0, $e);
|
||||
}
|
||||
|
||||
$body .= $this->asn1->encodeInteger($nonce);
|
||||
}
|
||||
|
||||
$body .= $this->asn1->encodeBoolean(true);
|
||||
return $this->asn1->encodeSequence($body);
|
||||
}
|
||||
|
||||
/**
|
||||
* Extract the timestamp token from a DER-encoded RFC 3161 TimeStampResp.
|
||||
*
|
||||
* @param string $response DER-encoded timestamp response.
|
||||
*
|
||||
* @return string DER-encoded timestamp token (ContentInfo).
|
||||
*
|
||||
* @throws Exception If the response is empty, malformed, or rejected.
|
||||
*/
|
||||
public function parseResponse(string $response): string
|
||||
{
|
||||
if ($response === '') {
|
||||
throw new Exception('Empty TSA response');
|
||||
}
|
||||
|
||||
$offset = 0;
|
||||
$root = $this->asn1->readTlv($response, $offset);
|
||||
if ($root['tag'] !== 0x30 || $offset !== \strlen($response)) {
|
||||
throw new Exception('Invalid TSA response');
|
||||
}
|
||||
|
||||
$inner = 0;
|
||||
$statusSeq = $this->asn1->readTlv($root['value'], $inner);
|
||||
if ($statusSeq['tag'] !== 0x30) {
|
||||
throw new Exception('Invalid TSA status response');
|
||||
}
|
||||
|
||||
$statusOffset = 0;
|
||||
$status = $this->asn1->readTlv($statusSeq['value'], $statusOffset);
|
||||
if ($status['tag'] !== 0x02) {
|
||||
throw new Exception('Invalid TSA status code');
|
||||
}
|
||||
|
||||
$statusCode = $this->asn1->decodeInteger($status['value']);
|
||||
if ($statusCode !== 0 && $statusCode !== 1) {
|
||||
throw new Exception('TSA request rejected');
|
||||
}
|
||||
|
||||
if ($inner >= \strlen($root['value'])) {
|
||||
throw new Exception('Missing TSA token');
|
||||
}
|
||||
|
||||
$token = $this->asn1->readTlv($root['value'], $inner);
|
||||
if ($token['tag'] !== 0x30) {
|
||||
throw new Exception('Invalid TSA token structure');
|
||||
}
|
||||
|
||||
return $token['raw'];
|
||||
}
|
||||
|
||||
/**
|
||||
* Build the request, submit it through the given transport, and parse the
|
||||
* returned token.
|
||||
*
|
||||
* @param string $signature Signature bytes to timestamp.
|
||||
* @param callable $transport Receives the DER request string and must
|
||||
* return the DER response string.
|
||||
*
|
||||
* @throws Exception If encoding, transport, or parsing fails.
|
||||
*/
|
||||
public function requestToken(string $signature, callable $transport): string
|
||||
{
|
||||
$request = $this->buildRequest($signature);
|
||||
|
||||
/** @var mixed $response */
|
||||
$response = $transport($request);
|
||||
if (!\is_string($response)) {
|
||||
throw new Exception('Invalid TSA transport response');
|
||||
}
|
||||
|
||||
return $this->parseResponse($response);
|
||||
}
|
||||
|
||||
/**
|
||||
* Map a digest algorithm name to its OID.
|
||||
*
|
||||
* @throws Exception If the algorithm is not supported.
|
||||
*/
|
||||
public function hashAlgorithmOid(string $algorithm): string
|
||||
{
|
||||
return match ($algorithm) {
|
||||
'sha256' => '2.16.840.1.101.3.4.2.1',
|
||||
'sha384' => '2.16.840.1.101.3.4.2.2',
|
||||
'sha512' => '2.16.840.1.101.3.4.2.3',
|
||||
default => throw new Exception('Unsupported TSA hash algorithm: ' . $algorithm),
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,98 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
/**
|
||||
* Config.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\Timestamp;
|
||||
|
||||
use Com\Tecnick\Pdf\Sign\DigestAlgorithm;
|
||||
use Com\Tecnick\Pdf\Sign\Exception;
|
||||
|
||||
/**
|
||||
* Com\Tecnick\Pdf\Sign\Timestamp\Config
|
||||
*
|
||||
* Immutable RFC 3161 Time Stamping Authority (TSA) configuration. The codec
|
||||
* fields (hash algorithm, policy OID, nonce) drive request construction; the
|
||||
* transport fields (host, timeout, credentials, CA file, peer verification)
|
||||
* are consumed by the caller-provided transport.
|
||||
*
|
||||
* @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 Config
|
||||
{
|
||||
/**
|
||||
* Supported TSA message-imprint digest algorithms.
|
||||
*
|
||||
* @var list<string>
|
||||
*/
|
||||
public const HASH_ALGORITHMS = ['sha256', 'sha384', 'sha512'];
|
||||
|
||||
/**
|
||||
* Selected message-imprint digest algorithm (one of HASH_ALGORITHMS).
|
||||
*/
|
||||
public readonly string $hashAlgorithm;
|
||||
|
||||
/**
|
||||
* @param string $host TSA endpoint URL (https).
|
||||
* @param string|DigestAlgorithm $hashAlgorithm Message-imprint digest name or enum case.
|
||||
* @param string $policyOid Optional requested TSA policy OID (dotted form).
|
||||
* @param bool $nonceEnabled Add a random nonce to the request.
|
||||
* @param int $timeout Transport timeout in seconds (>= 1).
|
||||
* @param bool $verifyPeer Validate the TSA TLS certificate.
|
||||
* @param string $username Optional HTTP basic-auth username.
|
||||
* @param string $password Optional HTTP basic-auth password.
|
||||
* @param string $cert Optional CA bundle path for the transport.
|
||||
*
|
||||
* @throws Exception If any option is invalid.
|
||||
*/
|
||||
public function __construct(
|
||||
public readonly string $host,
|
||||
string|DigestAlgorithm $hashAlgorithm = 'sha256',
|
||||
public readonly string $policyOid = '',
|
||||
public readonly bool $nonceEnabled = true,
|
||||
public readonly int $timeout = 5,
|
||||
public readonly bool $verifyPeer = true,
|
||||
public readonly string $username = '',
|
||||
#[\SensitiveParameter]
|
||||
public readonly string $password = '',
|
||||
public readonly string $cert = '',
|
||||
) {
|
||||
$hashAlgorithm = $hashAlgorithm instanceof DigestAlgorithm ? $hashAlgorithm->value : $hashAlgorithm;
|
||||
$this->hashAlgorithm = $hashAlgorithm;
|
||||
|
||||
if ($host === '') {
|
||||
throw new Exception('Invalid TSA host');
|
||||
}
|
||||
|
||||
if (!\in_array($hashAlgorithm, self::HASH_ALGORITHMS, true)) {
|
||||
throw new Exception('Invalid TSA hash algorithm: ' . $hashAlgorithm);
|
||||
}
|
||||
|
||||
if ($policyOid !== '' && \preg_match('/^\d+(?:\.\d+)+$/', $policyOid) !== 1) {
|
||||
throw new Exception('Invalid TSA policy OID: ' . $policyOid);
|
||||
}
|
||||
|
||||
if ($timeout < 1) {
|
||||
throw new Exception('Invalid TSA timeout: ' . $timeout);
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user