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
+317
View File
@@ -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;
}
}
+347
View File
@@ -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
View File
@@ -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);
}
}
+34
View File
@@ -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;
}
}
+187
View File
@@ -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";
}
}
+183
View File
@@ -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";
}
}
+79
View File
@@ -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
View File
@@ -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);
}
}
}