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
+186
View File
@@ -0,0 +1,186 @@
<?php
declare(strict_types=1);
/**
* Asn1Test.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 Test\Cms;
use Com\Tecnick\Pdf\Sign\Cms\Asn1;
use Com\Tecnick\Pdf\Sign\Exception;
use PHPUnit\Framework\TestCase;
/**
* Asn1 Test
*
* @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 Asn1Test extends TestCase
{
private Asn1 $asn1;
protected function setUp(): void
{
$this->asn1 = new Asn1();
}
public function testEncodeLengthShortForm(): void
{
$this->assertSame("\x05", $this->asn1->encodeLength(5));
$this->assertSame("\x7F", $this->asn1->encodeLength(127));
}
public function testEncodeLengthLongForm(): void
{
$this->assertSame("\x81\x80", $this->asn1->encodeLength(128));
$this->assertSame("\x82\x01\x00", $this->asn1->encodeLength(256));
}
public function testEncodeInteger(): void
{
$this->assertSame("\x02\x01\x00", $this->asn1->encodeInteger(0));
$this->assertSame("\x02\x01\x7F", $this->asn1->encodeInteger(127));
$this->assertSame("\x02\x02\x00\xFF", $this->asn1->encodeInteger(255));
$this->assertSame("\x02\x02\x01\x00", $this->asn1->encodeInteger(256));
}
public function testEncodeIntegerBytesTrimsAndPads(): void
{
$this->assertSame("\x02\x01\x7F", $this->asn1->encodeIntegerBytes("\x00\x7F"));
$this->assertSame("\x02\x02\x00\x80", $this->asn1->encodeIntegerBytes("\x80"));
}
public function testEncodeBoolean(): void
{
$this->assertSame("\x01\x01\xFF", $this->asn1->encodeBoolean(true));
$this->assertSame("\x01\x01\x00", $this->asn1->encodeBoolean(false));
}
public function testEncodeNull(): void
{
$this->assertSame("\x05\x00", $this->asn1->encodeNull());
}
public function testEncodeOctetStringSequenceSet(): void
{
$this->assertSame("\x04\x02AB", $this->asn1->encodeOctetString('AB'));
$this->assertSame("\x30\x02AB", $this->asn1->encodeSequence('AB'));
$this->assertSame("\x31\x02AB", $this->asn1->encodeSet('AB'));
}
public function testEncodeContext(): void
{
$this->assertSame("\xA0\x02AB", $this->asn1->encodeContext(0, 'AB'));
$this->assertSame("\xA3\x02AB", $this->asn1->encodeContext(3, 'AB'));
}
public function testEncodeObjectIdentifier(): void
{
// sha256WithRSAEncryption: 1.2.840.113549.1.1.11
$this->assertSame(
'06092a864886f70d01010b',
\bin2hex($this->asn1->encodeObjectIdentifier('1.2.840.113549.1.1.11')),
);
}
public function testReadTlvRoundTrip(): void
{
$der = $this->asn1->encodeSequence($this->asn1->encodeInteger(256));
$offset = 0;
$tlv = $this->asn1->readTlv($der, $offset);
$this->assertSame(0x30, $tlv['tag']);
$this->assertSame(\strlen($der), $offset);
$this->assertSame($der, $tlv['raw']);
$inner = 0;
$intTlv = $this->asn1->readTlv($tlv['value'], $inner);
$this->assertSame(0x02, $intTlv['tag']);
$this->assertSame(256, $this->asn1->decodeInteger($intTlv['value']));
}
public function testReadTlvRejectsTruncatedData(): void
{
$this->expectException(Exception::class);
$offset = 0;
$this->asn1->readTlv("\x30\x05\x00", $offset);
}
public function testDecodeIntegerRejectsEmpty(): void
{
$this->expectException(Exception::class);
$this->asn1->decodeInteger('');
}
public function testEncodeIntegerBytesEmptyInputYieldsZero(): void
{
$this->assertSame("\x02\x01\x00", $this->asn1->encodeIntegerBytes(''));
}
public function testEncodeObjectIdentifierRejectsSingleArc(): void
{
$this->expectException(Exception::class);
$this->asn1->encodeObjectIdentifier('1');
}
public function testEncodeObjectIdentifierClampsNegativeArc(): void
{
$this->assertSame('06022a00', \bin2hex($this->asn1->encodeObjectIdentifier('1.2.-1')));
}
public function testReadTlvRejectsEmptyData(): void
{
$this->expectException(Exception::class);
$offset = 0;
$this->asn1->readTlv('', $offset);
}
public function testReadTlvRejectsMissingLength(): void
{
$this->expectException(Exception::class);
$offset = 0;
$this->asn1->readTlv("\x30", $offset);
}
public function testReadTlvRejectsUnsupportedLongFormLength(): void
{
$this->expectException(Exception::class);
$offset = 0;
// 0x85 announces a 5-octet length, which exceeds the supported 4 octets.
$this->asn1->readTlv("\x04\x85\x00\x00\x00\x00\x00", $offset);
}
public function testReadTlvHandlesLongFormLength(): void
{
// A 200-byte payload forces a multi-octet (long-form) DER length.
$payload = \str_repeat("\x41", 200);
$der = $this->asn1->encodeOctetString($payload);
$offset = 0;
$tlv = $this->asn1->readTlv($der, $offset);
$this->assertSame(0x04, $tlv['tag']);
$this->assertSame($payload, $tlv['value']);
$this->assertSame(\strlen($der), $offset);
}
// Coverage note: Asn1::encodeLength() throws on a length needing more than
// 127 octets to represent. That requires content larger than 2^1016 bytes,
// which is unrepresentable by a PHP int and unallocatable, so the guard is
// defensive and cannot be exercised in a unit test.
}
@@ -0,0 +1,422 @@
<?php
declare(strict_types=1);
/**
* BuilderTest.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 Test\Cms;
use Com\Tecnick\Pdf\Sign\Cms\Asn1;
use Com\Tecnick\Pdf\Sign\Cms\Builder;
use Com\Tecnick\Pdf\Sign\Exception;
use OpenSSLAsymmetricKey;
use PHPUnit\Framework\TestCase;
/**
* CMS Builder Test
*
* @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 BuilderTest extends TestCase
{
private const SIGNING_TIME = 1_700_000_000;
private Asn1 $asn1;
protected function setUp(): void
{
$this->asn1 = new Asn1();
}
public function testSignRsaSha256ProducesVerifiableCms(): void
{
$cred = $this->makeCredential('rsa');
$data = 'The quick brown fox jumps over the lazy dog.';
$builder = new Builder($this->asn1);
$cms = $builder->sign($data, $cred['cert_der'], $cred['key'], [], 'sha256', self::SIGNING_TIME);
$parts = $this->parseSignerInfo($cms);
$this->assertSame(0xA0, $parts['signed_attrs']['tag']);
$this->assertSame(0x04, $parts['signature']['tag']);
$this->assertSame(0xA0, $parts['certificates']['tag']);
$this->assertStringContainsString($cred['cert_der'], $parts['certificates']['value']);
// Cryptographically verify the signature over the DER SET OF signed attributes.
$this->assertVerifies($parts, $cred['cert_pem'], OPENSSL_ALGO_SHA256);
// content-type present and equal to id-data.
$contentType = $this->attributeValue($parts['signed_attrs']['value'], '1.2.840.113549.1.9.3');
$this->assertNotNull($contentType);
$this->assertSame($this->asn1->encodeObjectIdentifier('1.2.840.113549.1.7.1'), $contentType['raw']);
// signing-time is a UTCTime for a 2023 timestamp.
$signingTime = $this->attributeValue($parts['signed_attrs']['value'], '1.2.840.113549.1.9.5');
$this->assertNotNull($signingTime);
$this->assertSame(0x17, $signingTime['tag']);
// message-digest equals SHA-256 of the content.
$messageDigest = $this->attributeValue($parts['signed_attrs']['value'], '1.2.840.113549.1.9.4');
$this->assertNotNull($messageDigest);
$this->assertSame(\hash('sha256', $data, true), $messageDigest['value']);
// signing-certificate-v2 carries the SHA-256 hash of the signer certificate;
// for SHA-256 the ESSCertIDv2 hashAlgorithm is omitted so certHash is first.
$certHash = $this->firstCertHash('1.2.840.113549.1.9.16.2.47', $parts['signed_attrs']['value']);
$this->assertSame(0x04, $certHash['tag']);
$this->assertSame(\hash('sha256', $cred['cert_der'], true), $certHash['value']);
}
public function testSignOmitsSigningTimeForPadesBaseline(): void
{
$cred = $this->makeCredential('rsa');
$data = 'PAdES-BASELINE forbids the CMS signing-time attribute.';
$builder = new Builder($this->asn1);
// includeSigningTime = false: the PAdES-BASELINE case, where the signing time
// is carried by the /M signature dictionary entry rather than the CMS.
$cms = $builder->sign($data, $cred['cert_der'], $cred['key'], [], 'sha256', self::SIGNING_TIME, null, false);
$parts = $this->parseSignerInfo($cms);
// The signature still verifies over the (smaller) DER SET OF signed attributes.
$this->assertVerifies($parts, $cred['cert_pem'], OPENSSL_ALGO_SHA256);
// signing-time (1.2.840.113549.1.9.5) is absent.
$this->assertNull($this->attributeValue($parts['signed_attrs']['value'], '1.2.840.113549.1.9.5'));
// The other mandatory signed attributes remain present.
$this->assertNotNull($this->attributeValue($parts['signed_attrs']['value'], '1.2.840.113549.1.9.3'));
$this->assertNotNull($this->attributeValue($parts['signed_attrs']['value'], '1.2.840.113549.1.9.4'));
$this->assertNotNull($this->attributeValue($parts['signed_attrs']['value'], '1.2.840.113549.1.9.16.2.47'));
}
public function testSignEcSha256ProducesVerifiableCms(): void
{
$cred = $this->makeCredential('ec');
$data = 'elliptic-curve payload';
$builder = new Builder($this->asn1);
$cms = $builder->sign($data, $cred['cert_der'], $cred['key'], [], 'sha256', self::SIGNING_TIME);
$parts = $this->parseSignerInfo($cms);
$this->assertVerifies($parts, $cred['cert_pem'], OPENSSL_ALGO_SHA256);
}
public function testSignRsaSha384IncludesEssCertHashAlgorithm(): void
{
$cred = $this->makeCredential('rsa');
$builder = new Builder($this->asn1);
$cms = $builder->sign('data', $cred['cert_der'], $cred['key'], [], 'sha384', self::SIGNING_TIME);
$parts = $this->parseSignerInfo($cms);
$this->assertVerifies($parts, $cred['cert_pem'], OPENSSL_ALGO_SHA384);
// For a non-default digest, ESSCertIDv2 begins with the hashAlgorithm SEQUENCE.
$scv2 = $this->attributeValue($parts['signed_attrs']['value'], '1.2.840.113549.1.9.16.2.47');
$this->assertNotNull($scv2);
$certsOffset = 0;
$certs = $this->asn1->readTlv($scv2['value'], $certsOffset);
$essOffset = 0;
$ess = $this->asn1->readTlv($certs['value'], $essOffset);
$firstOffset = 0;
$first = $this->asn1->readTlv($ess['value'], $firstOffset);
$this->assertSame(0x30, $first['tag']);
}
public function testSignRsaSha512ProducesVerifiableCms(): void
{
$cred = $this->makeCredential('rsa');
$builder = new Builder($this->asn1);
$cms = $builder->sign('data', $cred['cert_der'], $cred['key'], [], 'sha512', self::SIGNING_TIME);
$parts = $this->parseSignerInfo($cms);
$this->assertVerifies($parts, $cred['cert_pem'], OPENSSL_ALGO_SHA512);
}
public function testSignEmbedsChainCertificates(): void
{
$cred = $this->makeCredential('rsa');
$chainDer = $this->pemToDer((string) \file_get_contents(__DIR__ . '/../data/ocsp_ca.pem'));
$builder = new Builder($this->asn1);
$cms = $builder->sign('data', $cred['cert_der'], $cred['key'], [$chainDer], 'sha256', self::SIGNING_TIME);
$parts = $this->parseSignerInfo($cms);
$this->assertStringContainsString($cred['cert_der'], $parts['certificates']['value']);
$this->assertStringContainsString($chainDer, $parts['certificates']['value']);
}
public function testSignWithoutTimestampHasNoUnsignedAttributes(): void
{
$cred = $this->makeCredential('rsa');
$builder = new Builder($this->asn1);
$cms = $builder->sign('data', $cred['cert_der'], $cred['key'], [], 'sha256', self::SIGNING_TIME);
$parts = $this->parseSignerInfo($cms);
$this->assertNull($parts['unsigned_attrs']);
}
public function testSignEmbedsSignatureTimestampUnsignedAttribute(): void
{
$cred = $this->makeCredential('rsa');
$token = $this->asn1->encodeSequence($this->asn1->encodeOctetString('fake-rfc3161-token'));
$captured = '';
$provider = static function (string $signature) use (&$captured, $token): string {
$captured = $signature;
return $token;
};
$builder = new Builder($this->asn1);
$cms = $builder->sign('data', $cred['cert_der'], $cred['key'], [], 'sha256', self::SIGNING_TIME, $provider);
$parts = $this->parseSignerInfo($cms);
// The signature is cryptographically unchanged by the added unsigned attribute.
$this->assertVerifies($parts, $cred['cert_pem'], OPENSSL_ALGO_SHA256);
// The provider timestamps the raw SignerInfo signature bytes.
$this->assertSame($parts['signature']['value'], $captured);
// unsignedAttrs is a [1] IMPLICIT context tag carrying id-aa-signatureTimeStampToken.
$this->assertNotNull($parts['unsigned_attrs']);
$this->assertSame(0xA1, $parts['unsigned_attrs']['tag']);
$tstValue = $this->attributeValue($parts['unsigned_attrs']['value'], '1.2.840.113549.1.9.16.2.14');
$this->assertNotNull($tstValue);
$this->assertSame($token, $tstValue['raw']);
}
public function testSignRejectsEmptySignatureTimestampToken(): void
{
$cred = $this->makeCredential('rsa');
$provider = static fn(): string => '';
$builder = new Builder($this->asn1);
$this->expectException(Exception::class);
$builder->sign('data', $cred['cert_der'], $cred['key'], [], 'sha256', self::SIGNING_TIME, $provider);
}
public function testSignUsesGeneralizedTimeForFarFuture(): void
{
$cred = $this->makeCredential('rsa');
$builder = new Builder($this->asn1);
// 2100-01-01T00:00:00Z is outside the UTCTime range (1950-2049).
$cms = $builder->sign('data', $cred['cert_der'], $cred['key'], [], 'sha256', 4_102_444_800);
$parts = $this->parseSignerInfo($cms);
$signingTime = $this->attributeValue($parts['signed_attrs']['value'], '1.2.840.113549.1.9.5');
$this->assertNotNull($signingTime);
$this->assertSame(0x18, $signingTime['tag']);
}
public function testSignRejectsUnsupportedDigest(): void
{
$cred = $this->makeCredential('rsa');
$builder = new Builder($this->asn1);
$this->expectException(Exception::class);
$builder->sign('data', $cred['cert_der'], $cred['key'], [], 'md5', self::SIGNING_TIME);
}
public function testSignFailsWithNonSigningKey(): void
{
$cred = $this->makeCredential('rsa');
$publicKey = \openssl_pkey_get_public($cred['cert_pem']);
if ($publicKey === false) {
$this->fail('Unable to load public key');
}
$builder = new Builder($this->asn1);
$this->expectException(Exception::class);
\set_error_handler(static fn(): bool => true);
try {
$builder->sign('data', $cred['cert_der'], $publicKey, [], 'sha256', self::SIGNING_TIME);
} finally {
\restore_error_handler();
}
}
public function testSignRejectsUnsupportedKeyType(): void
{
$cred = $this->makeCredential('dsa');
$builder = new Builder($this->asn1);
$this->expectException(Exception::class);
$builder->sign('data', $cred['cert_der'], $cred['key'], [], 'sha256', self::SIGNING_TIME);
}
/**
* Generate a private key and a matching self-signed certificate.
*
* @return array{key: OpenSSLAsymmetricKey, cert_pem: string, cert_der: string}
*/
private function makeCredential(string $keyType): array
{
$config = [
'config' => __DIR__ . '/../../openssl.cnf',
'digest_alg' => 'sha256',
'private_key_bits' => 2048,
'private_key_type' => OPENSSL_KEYTYPE_RSA,
];
if ($keyType === 'ec') {
$config['private_key_type'] = OPENSSL_KEYTYPE_EC;
$config['curve_name'] = 'prime256v1';
} elseif ($keyType === 'dsa') {
$config['private_key_type'] = OPENSSL_KEYTYPE_DSA;
$config['private_key_bits'] = 1024;
}
$key = \openssl_pkey_new($config);
if (!$key instanceof OpenSSLAsymmetricKey) {
$this->markTestSkipped($keyType . ' key generation is not available');
}
$csr = \openssl_csr_new(['commonName' => 'tc-lib-pdf-sign signer'], $key, $config);
if (!$csr instanceof \OpenSSLCertificateSigningRequest) {
$this->markTestSkipped('CSR generation failed for ' . $keyType);
}
$cert = \openssl_csr_sign($csr, null, $key, 365, $config);
if (!$cert instanceof \OpenSSLCertificate) {
$this->markTestSkipped('Certificate signing failed for ' . $keyType);
}
$certPem = '';
\openssl_x509_export($cert, $certPem);
return ['key' => $key, 'cert_pem' => $certPem, 'cert_der' => $this->pemToDer($certPem)];
}
private function pemToDer(string $pem): string
{
$stripped = (string) \preg_replace('/-----[^-]+-----|\s+/', '', $pem);
$der = \base64_decode($stripped, true);
if ($der === false) {
$this->fail('Invalid PEM');
}
return $der;
}
/**
* Verify the SignerInfo signature over the reconstructed DER SET OF signed attributes.
*
* @param array{signed_attrs: array{tag:int,value:string,raw:string}, signature: array{tag:int,value:string,raw:string}, certificates: array{tag:int,value:string,raw:string}, unsigned_attrs: array{tag:int,value:string,raw:string}|null} $parts
*/
private function assertVerifies(array $parts, string $certPem, int $opensslAlgo): void
{
$publicKey = \openssl_pkey_get_public($certPem);
if ($publicKey === false) {
$this->fail('Unable to load public key');
}
$signedAttrsSet = $this->asn1->encodeSet($parts['signed_attrs']['value']);
$result = \openssl_verify($signedAttrsSet, $parts['signature']['value'], $publicKey, $opensslAlgo);
$this->assertSame(1, $result);
}
/**
* Descend into a SigningCertificate attribute and return the ESSCertID certHash TLV.
*
* @return array{tag: int, value: string, raw: string}
*/
private function firstCertHash(string $oid, string $attrsDer): array
{
$value = $this->attributeValue($attrsDer, $oid);
$this->assertNotNull($value);
$certsOffset = 0;
$certs = $this->asn1->readTlv($value['value'], $certsOffset);
$essOffset = 0;
$ess = $this->asn1->readTlv($certs['value'], $essOffset);
$hashOffset = 0;
return $this->asn1->readTlv($ess['value'], $hashOffset);
}
/**
* Find an Attribute by OID and return the first value TLV of its value SET.
*
* @return array{tag: int, value: string, raw: string}|null
*/
private function attributeValue(string $attrsDer, string $oid): ?array
{
$oidDer = $this->asn1->encodeObjectIdentifier($oid);
$offset = 0;
$length = \strlen($attrsDer);
while ($offset < $length) {
$attribute = $this->asn1->readTlv($attrsDer, $offset);
$inner = 0;
$attrOid = $this->asn1->readTlv($attribute['value'], $inner);
if ($attrOid['raw'] === $oidDer) {
$set = $this->asn1->readTlv($attribute['value'], $inner);
$valueOffset = 0;
return $this->asn1->readTlv($set['value'], $valueOffset);
}
}
return null;
}
/**
* Navigate a CMS ContentInfo to the SignerInfo fields under test.
*
* @return array{signed_attrs: array{tag:int,value:string,raw:string}, signature: array{tag:int,value:string,raw:string}, certificates: array{tag:int,value:string,raw:string}, unsigned_attrs: array{tag:int,value:string,raw:string}|null}
*/
private function parseSignerInfo(string $cms): array
{
$offset = 0;
$contentInfo = $this->asn1->readTlv($cms, $offset);
$ciOffset = 0;
$this->asn1->readTlv($contentInfo['value'], $ciOffset); // contentType OID
$explicit = $this->asn1->readTlv($contentInfo['value'], $ciOffset); // [0] EXPLICIT
$sdOffset = 0;
$signedData = $this->asn1->readTlv($explicit['value'], $sdOffset);
$sdInner = 0;
$this->asn1->readTlv($signedData['value'], $sdInner); // version
$this->asn1->readTlv($signedData['value'], $sdInner); // digestAlgorithms
$this->asn1->readTlv($signedData['value'], $sdInner); // encapContentInfo
$certificates = $this->asn1->readTlv($signedData['value'], $sdInner); // certificates [0]
$signerInfos = $this->asn1->readTlv($signedData['value'], $sdInner); // signerInfos SET
$siOffset = 0;
$signerInfo = $this->asn1->readTlv($signerInfos['value'], $siOffset);
$siInner = 0;
$this->asn1->readTlv($signerInfo['value'], $siInner); // version
$this->asn1->readTlv($signerInfo['value'], $siInner); // sid
$this->asn1->readTlv($signerInfo['value'], $siInner); // digestAlgorithm
$signedAttrs = $this->asn1->readTlv($signerInfo['value'], $siInner); // [0] IMPLICIT
$this->asn1->readTlv($signerInfo['value'], $siInner); // signatureAlgorithm
$signature = $this->asn1->readTlv($signerInfo['value'], $siInner); // signature
$unsignedAttrs = null;
if ($siInner < \strlen($signerInfo['value'])) {
$unsignedAttrs = $this->asn1->readTlv($signerInfo['value'], $siInner); // [1] IMPLICIT
}
return [
'signed_attrs' => $signedAttrs,
'signature' => $signature,
'certificates' => $certificates,
'unsigned_attrs' => $unsignedAttrs,
];
}
}
+113
View File
@@ -0,0 +1,113 @@
<?php
declare(strict_types=1);
/**
* ConfigTest.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 Test;
use Com\Tecnick\Pdf\Sign\Config;
use Com\Tecnick\Pdf\Sign\Exception;
use PHPUnit\Framework\TestCase;
/**
* Config Test
*
* @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 ConfigTest extends TestCase
{
public function testDefaultsAreLegacy(): void
{
$cfg = new Config();
$this->assertSame(Config::PROFILE_LEGACY, $cfg->profile);
$this->assertSame('sha256', $cfg->digestAlgorithm);
$this->assertSame(2, $cfg->certType);
$this->assertFalse($cfg->isPades());
$this->assertSame('adbe.pkcs7.detached', $cfg->subFilter());
}
public function testPadesProfile(): void
{
$cfg = new Config(Config::PROFILE_PADES_B_T, 'sha384', 1);
$this->assertTrue($cfg->isPades());
$this->assertSame('ETSI.CAdES.detached', $cfg->subFilter());
$this->assertSame('sha384', $cfg->digestAlgorithm);
$this->assertSame(1, $cfg->certType);
}
public function testInvalidProfileThrows(): void
{
$this->expectException(Exception::class);
new Config('bogus');
}
public function testInvalidDigestThrows(): void
{
$this->expectException(Exception::class);
new Config(Config::PROFILE_PADES_B_B, 'md5');
}
public function testInvalidCertTypeThrows(): void
{
$this->expectException(Exception::class);
new Config(Config::PROFILE_LEGACY, 'sha256', 4);
}
public function testFromArrayDefaults(): void
{
$cfg = Config::fromArray([]);
$this->assertSame(Config::PROFILE_LEGACY, $cfg->profile);
$this->assertSame('sha256', $cfg->digestAlgorithm);
$this->assertSame(2, $cfg->certType);
}
public function testFromArrayValues(): void
{
$cfg = Config::fromArray([
'profile' => Config::PROFILE_PADES_B_LTA,
'digest_algorithm' => 'sha512',
'cert_type' => 3,
]);
$this->assertSame(Config::PROFILE_PADES_B_LTA, $cfg->profile);
$this->assertSame('sha512', $cfg->digestAlgorithm);
$this->assertSame(3, $cfg->certType);
$this->assertTrue($cfg->isPades());
}
public function testFromArrayInvalidTypeThrows(): void
{
$this->expectException(Exception::class);
Config::fromArray(['cert_type' => '2']);
}
public function testFromArrayRejectsNonStringProfile(): void
{
$this->expectException(Exception::class);
Config::fromArray(['profile' => 123]);
}
public function testFromArrayRejectsNonStringDigest(): void
{
$this->expectException(Exception::class);
Config::fromArray(['digest_algorithm' => 123]);
}
}
@@ -0,0 +1,89 @@
<?php
declare(strict_types=1);
/**
* DigestAlgorithmTest.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 Test;
use Com\Tecnick\Pdf\Sign\Config;
use Com\Tecnick\Pdf\Sign\DigestAlgorithm;
use Com\Tecnick\Pdf\Sign\Exception;
use Com\Tecnick\Pdf\Sign\Timestamp\Config as TimestampConfig;
use PHPUnit\Framework\TestCase;
/**
* DigestAlgorithm enum test
*
* @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
*/
class DigestAlgorithmTest extends TestCase
{
public function testCaseBackingValues(): void
{
$this->assertSame('sha256', DigestAlgorithm::Sha256->value);
$this->assertSame('sha384', DigestAlgorithm::Sha384->value);
$this->assertSame('sha512', DigestAlgorithm::Sha512->value);
}
public function testCasesMatchBothConfigSets(): void
{
$values = \array_map(static fn(DigestAlgorithm $case): string => $case->value, DigestAlgorithm::cases());
$this->assertSame(Config::DIGEST_ALGORITHMS, $values);
$this->assertSame(TimestampConfig::HASH_ALGORITHMS, $values);
}
public function testFromLooseCanonical(): void
{
$this->assertSame(DigestAlgorithm::Sha256, DigestAlgorithm::fromLoose('sha256'));
$this->assertSame(DigestAlgorithm::Sha512, DigestAlgorithm::fromLoose('sha512'));
}
public function testFromLoosePassesThroughEnumInstance(): void
{
$this->assertSame(DigestAlgorithm::Sha384, DigestAlgorithm::fromLoose(DigestAlgorithm::Sha384));
}
public function testFromLooseRoundTrip(): void
{
foreach (DigestAlgorithm::cases() as $case) {
$this->assertSame($case, DigestAlgorithm::fromLoose($case->value));
}
}
public function testFromLooseUnknownThrows(): void
{
$this->expectException(Exception::class);
DigestAlgorithm::fromLoose('md5');
}
public function testConfigAcceptsEnum(): void
{
$cfg = new Config(Config::PROFILE_LEGACY, DigestAlgorithm::Sha384);
$this->assertSame('sha384', $cfg->digestAlgorithm);
}
public function testTimestampConfigAcceptsEnum(): void
{
$cfg = new TimestampConfig(host: 'https://tsa.example.org', hashAlgorithm: DigestAlgorithm::Sha512);
$this->assertSame('sha512', $cfg->hashAlgorithm);
}
}
@@ -0,0 +1,193 @@
<?php
declare(strict_types=1);
/**
* ValidationMaterialTest.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 Test\Ltv;
use Com\Tecnick\Pdf\Sign\Exception;
use Com\Tecnick\Pdf\Sign\Ltv\ValidationMaterial;
use PHPUnit\Framework\TestCase;
/**
* ValidationMaterial Test
*
* @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 ValidationMaterialTest extends TestCase
{
private ValidationMaterial $material;
private string $ltvPem = '';
private string $caPem = '';
private string $leafDer = '';
private string $caDer = '';
protected function setUp(): void
{
$this->material = new ValidationMaterial();
$this->ltvPem = (string) \file_get_contents(__DIR__ . '/../data/ltv_cert.pem');
$this->caPem = (string) \file_get_contents(__DIR__ . '/../data/ocsp_ca.pem');
$leafPem = (string) \file_get_contents(__DIR__ . '/../data/ocsp_leaf.pem');
$this->leafDer = $this->pemToDer($leafPem);
$this->caDer = $this->pemToDer($this->caPem);
}
private function pemToDer(string $pem): string
{
$stripped = (string) \preg_replace('/-----[^-]+-----|\s+/', '', $pem);
$der = \base64_decode($stripped, true);
if ($der === false) {
$this->fail('Invalid PEM fixture');
}
return $der;
}
public function testCertificateOcspUrlsExtractsOnlyOcsp(): void
{
$urls = $this->material->certificateOcspUrls($this->ltvPem);
$this->assertSame(['http://ocsp.example.org/r'], $urls);
}
public function testCertificateCrlUrlsExtractsAll(): void
{
$urls = $this->material->certificateCrlUrls($this->ltvPem);
$this->assertSame(['http://crl.example.org/root.crl', 'http://crl2.example.org/root.crl'], $urls);
}
public function testUrlsEmptyWhenExtensionAbsent(): void
{
// The OCSP CA fixture carries no AIA or CRL distribution point extensions.
$this->assertSame([], $this->material->certificateOcspUrls($this->caPem));
$this->assertSame([], $this->material->certificateCrlUrls($this->caPem));
}
public function testCertificateUrlsEmptyForUnparseableCertificate(): void
{
// LTV collection is best-effort: a certificate that cannot be parsed yields no
// OCSP/CRL URLs rather than aborting the whole signing operation. The certificate
// is still embeddable because its DER bytes are obtained separately.
\set_error_handler(static fn(): bool => true);
try {
$this->assertSame([], $this->material->certificateOcspUrls('not-a-certificate'));
$this->assertSame([], $this->material->certificateCrlUrls('not-a-certificate'));
} finally {
\restore_error_handler();
}
}
public function testCertificatesDeduplicates(): void
{
$leafPem = (string) \file_get_contents(__DIR__ . '/../data/ocsp_leaf.pem');
$ders = $this->material->certificates([$leafPem, $leafPem, $this->caPem]);
$this->assertCount(2, $ders);
$this->assertSame([$this->leafDer, $this->caDer], $ders);
}
public function testCertificatesRejectsInvalidPem(): void
{
$this->expectException(Exception::class);
$this->material->certificates(["-----BEGIN CERTIFICATE-----\n@@@@\n-----END CERTIFICATE-----"]);
}
public function testFetchOcspBuildsRequestAndDeduplicates(): void
{
$captured = [];
$transport = static function (string $url, string $request) use (&$captured): string {
$captured[] = ['url' => $url, 'request' => $request];
return 'OCSP-RESPONSE';
};
$responses = $this->material->fetchOcsp(
$this->caDer,
$this->leafDer,
['http://ocsp.a.example', 'http://ocsp.b.example'],
$transport,
);
// Two URLs, identical responses collapse to one.
$this->assertSame(['OCSP-RESPONSE'], $responses);
$this->assertCount(2, $captured);
// The transport received a DER OCSP request (SEQUENCE).
$firstCapture = $captured[0] ?? null;
if (!\is_array($firstCapture)) {
$this->fail('Expected a captured OCSP request');
}
$this->assertSame("\x30", $firstCapture['request'][0]);
}
public function testFetchOcspSkipsFailingUrl(): void
{
$transport = static function (string $url): string {
if (\str_contains($url, 'bad')) {
throw new \RuntimeException('boom');
}
return 'RESP-' . $url;
};
$responses = $this->material->fetchOcsp(
$this->caDer,
$this->leafDer,
['http://bad.example', 'http://good.example'],
$transport,
);
$this->assertSame(['RESP-http://good.example'], $responses);
}
public function testFetchOcspReturnsEmptyWhenNoUrls(): void
{
$calls = 0;
$transport = static function () use (&$calls): string {
++$calls;
return 'X';
};
$this->assertSame([], $this->material->fetchOcsp($this->caDer, $this->leafDer, [], $transport));
$this->assertSame(0, $calls);
}
public function testFetchCrlDeduplicatesAndSkipsEmpty(): void
{
$transport = static function (string $url): string {
if (\str_contains($url, 'empty')) {
return '';
}
return 'CRL-DATA';
};
$responses = $this->material->fetchCrl(
['http://empty.example', 'http://a.example', 'http://b.example'],
$transport,
);
// Empty response skipped; the two identical CRLs collapse to one.
$this->assertSame(['CRL-DATA'], $responses);
}
}
@@ -0,0 +1,174 @@
<?php
declare(strict_types=1);
/**
* ClientTest.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 Test\Ocsp;
use Com\Tecnick\Pdf\Sign\Cms\Asn1;
use Com\Tecnick\Pdf\Sign\Exception;
use Com\Tecnick\Pdf\Sign\Ocsp\Client;
use PHPUnit\Framework\TestCase;
/**
* OCSP Client Test
*
* @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 ClientTest extends TestCase
{
private Asn1 $asn1;
private string $leafPem = '';
private string $leafDer = '';
private string $caDer = '';
protected function setUp(): void
{
$this->asn1 = new Asn1();
$this->leafPem = (string) \file_get_contents(__DIR__ . '/../data/ocsp_leaf.pem');
$this->leafDer = $this->pemToDer($this->leafPem);
$this->caDer = $this->pemToDer((string) \file_get_contents(__DIR__ . '/../data/ocsp_ca.pem'));
}
private function pemToDer(string $pem): string
{
$stripped = (string) \preg_replace('/-----[^-]+-----|\s+/', '', $pem);
$der = \base64_decode($stripped, true);
if ($der === false) {
$this->fail('Invalid PEM fixture');
}
return $der;
}
public function testExtractSubjectReturnsSubjectNotIssuer(): void
{
// The leaf subject (CN=...leaf) differs from its issuer (CN=...root CA),
// so this proves the subject field is read, not the issuer field.
$client = new Client($this->asn1);
$info = $client->extractSubjectAndPublicKey($this->leafDer);
$this->assertStringContainsString('tc-lib-pdf-sign leaf', $info['subject']);
$this->assertStringNotContainsString('root CA', $info['subject']);
$this->assertNotSame('', $info['public_key']);
}
public function testExtractSerialNumberMatchesOpenssl(): void
{
$parsed = \openssl_x509_parse($this->leafPem);
if (!\is_array($parsed)) {
$this->fail('Unable to parse leaf certificate');
}
$expectedHex = \strtolower($parsed['serialNumberHex']);
$client = new Client($this->asn1);
$serial = $client->extractSerialNumber($this->leafDer);
$this->assertSame($expectedHex, \bin2hex($serial));
}
public function testBuildProducesValidOcspRequest(): void
{
$client = new Client($this->asn1);
$req = $client->build($this->caDer, $this->leafDer);
// OCSPRequest ::= SEQ { tbsRequest SEQ { requestList SEQ OF { Request SEQ { CertID SEQ } } } }
$offset = 0;
$ocspRequest = $this->asn1->readTlv($req, $offset);
$this->assertSame(0x30, $ocspRequest['tag']);
$this->assertSame(\strlen($req), $offset);
$certId = $this->descend($ocspRequest['value'], 4); // tbsRequest, requestList, Request, CertID
$this->assertSame(0x30, $certId['tag']);
$inner = 0;
$algId = $this->asn1->readTlv($certId['value'], $inner);
$nameHash = $this->asn1->readTlv($certId['value'], $inner);
$keyHash = $this->asn1->readTlv($certId['value'], $inner);
$serial = $this->asn1->readTlv($certId['value'], $inner);
// SHA-1 CertID hashes are computed over the issuer certificate's subject and key.
$issuer = $client->extractSubjectAndPublicKey($this->caDer);
$this->assertSame(0x04, $nameHash['tag']);
$this->assertSame(\hash('sha1', $issuer['subject'], true), $nameHash['value']);
$this->assertSame(20, \strlen($nameHash['value']));
$this->assertSame(0x04, $keyHash['tag']);
$this->assertSame(\hash('sha1', $issuer['public_key'], true), $keyHash['value']);
$this->assertSame(20, \strlen($keyHash['value']));
// hashAlgorithm OID is SHA-1 (1.3.14.3.2.26).
$algOffset = 0;
$oid = $this->asn1->readTlv($algId['value'], $algOffset);
$this->assertSame($this->asn1->encodeObjectIdentifier('1.3.14.3.2.26'), $oid['raw']);
// serialNumber matches the leaf certificate.
$this->assertSame(0x02, $serial['tag']);
$this->assertSame($client->extractSerialNumber($this->leafDer), $serial['value']);
}
public function testFetchUsesTransport(): void
{
$captured = ['url' => '', 'request' => ''];
$transport = static function (string $url, string $request) use (&$captured): string {
$captured['url'] = $url;
$captured['request'] = $request;
return 'OCSP-RESPONSE-BYTES';
};
$client = new Client($this->asn1);
$result = $client->fetch('http://ocsp.example.org', $this->caDer, $this->leafDer, $transport);
$this->assertSame('OCSP-RESPONSE-BYTES', $result);
$this->assertSame('http://ocsp.example.org', $captured['url']);
$offset = 0;
$root = $this->asn1->readTlv($captured['request'], $offset);
$this->assertSame(0x30, $root['tag']);
}
public function testFetchRejectsNonStringTransportResult(): void
{
$transport = static fn(string $url, string $request): int => \strlen($url . $request);
$this->expectException(Exception::class);
$client = new Client($this->asn1);
$client->fetch('http://x', $this->caDer, $this->leafDer, $transport);
}
/**
* Read the first TLV, then descend into the first child $depth times.
*
* @param int<0, max> $depth
*
* @return array{tag: int, value: string, raw: string}
*/
private function descend(string $data, int $depth): array
{
$tlv = ['tag' => 0, 'value' => $data, 'raw' => $data];
for ($i = 0; $i < $depth; ++$i) {
$offset = 0;
$tlv = $this->asn1->readTlv($tlv['value'], $offset);
}
return $tlv;
}
}
@@ -0,0 +1,71 @@
<?php
declare(strict_types=1);
/**
* DocTimeStampTest.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 Test\Output;
use Com\Tecnick\Pdf\Sign\Output\DocTimeStamp;
use Com\Tecnick\Pdf\Sign\Output\Signature;
use PHPUnit\Framework\TestCase;
/**
* DocTimeStamp Output Test
*
* @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 DocTimeStampTest extends TestCase
{
private DocTimeStamp $docTimeStamp;
protected function setUp(): void
{
$this->docTimeStamp = new DocTimeStamp();
}
public function testValueObjectStructure(): void
{
$out = $this->docTimeStamp->valueObject(7);
$this->assertStringStartsWith("7 0 obj\n", $out);
$this->assertStringEndsWith(" >>\nendobj\n", $out);
$this->assertStringContainsString('/Type /DocTimeStamp /Filter /Adobe.PPKLite /SubFilter /ETSI.RFC3161', $out);
$this->assertStringContainsString(Signature::BYTE_RANGE_PLACEHOLDER, $out);
$this->assertStringContainsString(
'/Contents<' . \str_repeat('0', Signature::DEFAULT_CONTENTS_LENGTH) . '>',
$out,
);
// A document timestamp is not a signature: no /Sig, /Reference, /M, or /V.
$this->assertStringNotContainsString('/Type /Sig', $out);
$this->assertStringNotContainsString('/Reference', $out);
$this->assertStringNotContainsString('/M ', $out);
$this->assertStringNotContainsString('/V ', $out);
}
public function testCustomContentsLength(): void
{
$out = $this->docTimeStamp->valueObject(2, 64);
$this->assertStringContainsString('/Contents<' . \str_repeat('0', 64) . '>', $out);
$this->assertStringNotContainsString(\str_repeat('0', 65), $out);
}
}
@@ -0,0 +1,122 @@
<?php
declare(strict_types=1);
/**
* DssTest.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 Test\Output;
use Com\Tecnick\Pdf\Sign\Exception;
use Com\Tecnick\Pdf\Sign\Output\Dss;
use PHPUnit\Framework\TestCase;
/**
* DSS Output Test
*
* @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 DssTest extends TestCase
{
private Dss $dss;
protected function setUp(): void
{
$this->dss = new Dss();
}
public function testEmitReturnsNothingForEmptyMaterial(): void
{
$pon = 7;
$result = $this->dss->emit(['certs' => [], 'ocsp' => [], 'crls' => []], 'SIG', $pon);
$this->assertSame([], $result['objects']);
$this->assertSame(0, $result['object_id']);
$this->assertSame(7, $pon);
}
public function testEmitProducesStreamsVriAndDss(): void
{
$pon = 10;
$contents = 'CMS-SIGNATURE-BYTES';
$result = $this->dss->emit(
['certs' => ['CERT-DER'], 'ocsp' => ['OCSP-RESP'], 'crls' => ['CRL-DATA']],
$contents,
$pon,
);
// 3 streams (11,12,13), VRI (14), DSS (15).
$this->assertSame(15, $pon);
$this->assertSame(15, $result['object_id']);
// The whole map is keyed by object number, ready for an incremental xref.
$vriKey = \strtoupper(\sha1($contents));
$this->assertSame(
[
11 => "11 0 obj\n<< /Length 8 >>\nstream\nCERT-DER\nendstream\nendobj\n",
12 => "12 0 obj\n<< /Length 9 >>\nstream\nOCSP-RESP\nendstream\nendobj\n",
13 => "13 0 obj\n<< /Length 8 >>\nstream\nCRL-DATA\nendstream\nendobj\n",
14 => "14 0 obj\n<< /Type /VRI /Cert [ 11 0 R ] /OCSP [ 12 0 R ] /CRL [ 13 0 R ] >>\nendobj\n",
15 =>
"15 0 obj\n<< /Type /DSS /VRI << /"
. $vriKey
. ' 14 0 R >>'
. ' /Certs [ 11 0 R ] /OCSPs [ 12 0 R ] /CRLs [ 13 0 R ]'
. " >>\nendobj\n",
],
$result['objects'],
);
}
public function testEmitOmitsEmptyCategories(): void
{
$pon = 0;
$result = $this->dss->emit(['certs' => ['A', 'B'], 'ocsp' => [], 'crls' => []], 'SIG', $pon);
// 2 cert streams (1,2), VRI (3), DSS (4).
$this->assertSame(4, $result['object_id']);
$objects = \implode('', $result['objects']);
$this->assertStringContainsString('<< /Type /VRI /Cert [ 1 0 R 2 0 R ] >>', $objects);
$this->assertStringNotContainsString('/OCSP ', $objects);
$this->assertStringNotContainsString('/CRL ', $objects);
$this->assertStringContainsString('/Certs [ 1 0 R 2 0 R ]', $objects);
$this->assertStringNotContainsString('/OCSPs', $objects);
$this->assertStringNotContainsString('/CRLs', $objects);
}
public function testEmitEncryptsStreams(): void
{
$pon = 0;
$encryptor = static fn(string $data, int $objectId): string => 'E' . $objectId . ':' . $data;
$result = $this->dss->emit(['certs' => ['X'], 'ocsp' => [], 'crls' => []], 'SIG', $pon, $encryptor);
// Stream 1 carries the encrypted payload "E1:X" (length 4).
$objects = \implode('', $result['objects']);
$this->assertStringContainsString("1 0 obj\n<< /Length 4 >>\nstream\nE1:X\nendstream\nendobj\n", $objects);
}
public function testEmitRejectsNonStringEncryptorResult(): void
{
$pon = 0;
$encryptor = static fn(string $_data, int $objectId): int => $objectId;
$this->expectException(Exception::class);
$this->dss->emit(['certs' => ['X'], 'ocsp' => [], 'crls' => []], 'SIG', $pon, $encryptor);
}
}
@@ -0,0 +1,128 @@
<?php
declare(strict_types=1);
/**
* SignatureTest.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 Test\Output;
use Com\Tecnick\Pdf\Sign\Exception;
use Com\Tecnick\Pdf\Sign\Output\Signature;
use PHPUnit\Framework\TestCase;
/**
* Signature Output Test
*
* @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 SignatureTest extends TestCase
{
private Signature $signature;
protected function setUp(): void
{
$this->signature = new Signature();
}
private const DOCMDP_REFERENCE =
' /Reference [ << /Type /SigRef /TransformMethod /DocMDP'
. ' /TransformParams << /Type /TransformParams /P 2 /V /1.2 >> >> ]';
private const DATE_VALUE = "(D:20231114221320+00'00')";
public function testValueObjectWithReferenceAndInfo(): void
{
$out = $this->signature->valueObject(
12,
'ETSI.CAdES.detached',
self::DOCMDP_REFERENCE,
['Name' => 'Jane Doe', 'Location' => 'Rome', 'Reason' => 'Approval', 'ContactInfo' => 'jane@example.org'],
self::DATE_VALUE,
);
$this->assertStringStartsWith("12 0 obj\n", $out);
$this->assertStringEndsWith(" >>\nendobj\n", $out);
$this->assertStringContainsString('/Type /Sig /Filter /Adobe.PPKLite /SubFilter /ETSI.CAdES.detached', $out);
$this->assertStringContainsString(Signature::BYTE_RANGE_PLACEHOLDER, $out);
$this->assertStringContainsString(
'/Contents<' . \str_repeat('0', Signature::DEFAULT_CONTENTS_LENGTH) . '>',
$out,
);
$this->assertStringContainsString(self::DOCMDP_REFERENCE, $out);
$this->assertStringContainsString('/Name (Jane Doe)', $out);
$this->assertStringContainsString('/Location (Rome)', $out);
$this->assertStringContainsString('/Reason (Approval)', $out);
$this->assertStringContainsString('/ContactInfo (jane@example.org)', $out);
// The /M date token is appended verbatim (already encoded by the caller).
$this->assertStringContainsString(' /M ' . self::DATE_VALUE . ' >>', $out);
}
public function testEmptyReferenceIsOmitted(): void
{
$out = $this->signature->valueObject(3, 'ETSI.CAdES.detached', '', [], self::DATE_VALUE);
$this->assertStringNotContainsString('/Reference', $out);
$this->assertStringNotContainsString('/Name', $out);
$this->assertStringContainsString('/SubFilter /ETSI.CAdES.detached', $out);
}
public function testCustomContentsLength(): void
{
$out = $this->signature->valueObject(1, 'adbe.pkcs7.detached', '', [], self::DATE_VALUE, 128);
$this->assertStringContainsString('/Contents<' . \str_repeat('0', 128) . '>', $out);
$this->assertStringNotContainsString(\str_repeat('0', 129), $out);
}
public function testDefaultEncoderEscapesLiteralStrings(): void
{
$out = $this->signature->valueObject(1, 'adbe.pkcs7.detached', '', ['Name' => 'A (B) \\ C'], self::DATE_VALUE);
$this->assertStringContainsString('/Name (A \\(B\\) \\\\ C)', $out);
}
public function testUsesInjectedStringEncoder(): void
{
$encoder = static fn(string $text, int $_objectId): string => '<' . \bin2hex($text) . '>';
$out = $this->signature->valueObject(
5,
'ETSI.CAdES.detached',
'',
['Reason' => 'Hi'],
self::DATE_VALUE,
Signature::DEFAULT_CONTENTS_LENGTH,
$encoder,
);
$this->assertStringContainsString('/Reason <' . \bin2hex('Hi') . '>', $out);
}
public function testRejectsNonStringEncoderResult(): void
{
$encoder = static fn(string $_text, int $objectId): int => $objectId;
$this->expectException(Exception::class);
$this->signature->valueObject(
5,
'ETSI.CAdES.detached',
'',
['Reason' => 'Hi'],
self::DATE_VALUE,
Signature::DEFAULT_CONTENTS_LENGTH,
$encoder,
);
}
}
@@ -0,0 +1,82 @@
<?php
declare(strict_types=1);
/**
* WidgetTest.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 Test\Output;
use Com\Tecnick\Pdf\Sign\Exception;
use Com\Tecnick\Pdf\Sign\Output\Widget;
use PHPUnit\Framework\TestCase;
/**
* Widget Output Test
*
* @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 WidgetTest extends TestCase
{
private Widget $widget;
protected function setUp(): void
{
$this->widget = new Widget();
}
public function testSignedFieldWidget(): void
{
$out = $this->widget->annotation(8, '10.0 20.0 110.0 60.0', 5, 'Signature', 9, ' /AS /N /AP << /N 20 0 R >>');
$this->assertStringStartsWith("8 0 obj\n", $out);
$this->assertStringEndsWith(" >>\nendobj\n", $out);
$this->assertStringContainsString('/Type /Annot /Subtype /Widget', $out);
$this->assertStringContainsString('/Rect [10.0 20.0 110.0 60.0]', $out);
$this->assertStringContainsString('/P 5 0 R', $out);
$this->assertStringContainsString('/F 4 /FT /Sig', $out);
$this->assertStringContainsString('/T (Signature)', $out);
$this->assertStringContainsString('/Ff 0', $out);
$this->assertStringContainsString('/AS /N /AP << /N 20 0 R >>', $out);
$this->assertStringContainsString('/V 9 0 R', $out);
}
public function testEmptyFieldWidgetHasNoValueOrAppearance(): void
{
$out = $this->widget->annotation(4, '0 0 100 40', 5, 'Reviewer [002]');
$this->assertStringContainsString('/T (Reviewer [002])', $out);
$this->assertStringNotContainsString('/V ', $out);
$this->assertStringNotContainsString('/AP', $out);
}
public function testUsesInjectedStringEncoder(): void
{
$encoder = static fn(string $text, int $_objectId): string => '<' . \bin2hex($text) . '>';
$out = $this->widget->annotation(4, '0 0 1 1', 5, 'Sig', null, '', $encoder);
$this->assertStringContainsString('/T <' . \bin2hex('Sig') . '>', $out);
}
public function testRejectsNonStringEncoderResult(): void
{
$encoder = static fn(string $_text, int $objectId): int => $objectId;
$this->expectException(Exception::class);
$this->widget->annotation(4, '0 0 1 1', 5, 'Sig', null, '', $encoder);
}
}
@@ -0,0 +1,84 @@
<?php
declare(strict_types=1);
/**
* SignatureProfileTest.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 Test;
use Com\Tecnick\Pdf\Sign\Config;
use Com\Tecnick\Pdf\Sign\Exception;
use Com\Tecnick\Pdf\Sign\SignatureProfile;
use PHPUnit\Framework\TestCase;
/**
* SignatureProfile enum test
*
* @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
*/
class SignatureProfileTest extends TestCase
{
public function testCaseBackingValuesMatchConfigConstants(): void
{
$this->assertSame(Config::PROFILE_LEGACY, SignatureProfile::Legacy->value);
$this->assertSame(Config::PROFILE_PADES_B_B, SignatureProfile::PadesBB->value);
$this->assertSame(Config::PROFILE_PADES_B_T, SignatureProfile::PadesBT->value);
$this->assertSame(Config::PROFILE_PADES_B_LT, SignatureProfile::PadesBLT->value);
$this->assertSame(Config::PROFILE_PADES_B_LTA, SignatureProfile::PadesBLTA->value);
}
public function testFromLooseCanonical(): void
{
$this->assertSame(SignatureProfile::Legacy, SignatureProfile::fromLoose('legacy'));
$this->assertSame(SignatureProfile::PadesBLTA, SignatureProfile::fromLoose('pades-b-lta'));
}
public function testFromLoosePassesThroughEnumInstance(): void
{
$this->assertSame(SignatureProfile::PadesBT, SignatureProfile::fromLoose(SignatureProfile::PadesBT));
}
public function testFromLooseRoundTrip(): void
{
foreach (SignatureProfile::cases() as $case) {
$this->assertSame($case, SignatureProfile::fromLoose($case->value));
}
}
public function testFromLooseUnknownThrows(): void
{
$this->expectException(Exception::class);
SignatureProfile::fromLoose('bogus');
}
public function testConfigAcceptsEnum(): void
{
$cfg = new Config(SignatureProfile::PadesBLTA);
$this->assertSame(Config::PROFILE_PADES_B_LTA, $cfg->profile);
$this->assertTrue($cfg->isPades());
}
public function testFromArrayAcceptsEnum(): void
{
$cfg = Config::fromArray(['profile' => SignatureProfile::PadesBB]);
$this->assertSame(Config::PROFILE_PADES_B_B, $cfg->profile);
}
}
+291
View File
@@ -0,0 +1,291 @@
<?php
declare(strict_types=1);
/**
* SignerTest.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 Test;
use Com\Tecnick\Pdf\Sign\Cms\Asn1;
use Com\Tecnick\Pdf\Sign\Config;
use Com\Tecnick\Pdf\Sign\Exception;
use Com\Tecnick\Pdf\Sign\Signer;
use Com\Tecnick\Pdf\Sign\Timestamp\Client as TimestampClient;
use Com\Tecnick\Pdf\Sign\Timestamp\Config as TimestampConfig;
use OpenSSLAsymmetricKey;
use PHPUnit\Framework\TestCase;
/**
* Signer Test
*
* @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 SignerTest extends TestCase
{
private const SIGNING_TIME = 1_700_000_000;
private const OID_SIGNATURE_TIMESTAMP = '1.2.840.113549.1.9.16.2.14';
private const OID_SIGNING_TIME = '1.2.840.113549.1.9.5';
private Asn1 $asn1;
protected function setUp(): void
{
$this->asn1 = new Asn1();
}
public function testSignLegacyProfileHasNoSignatureTimestamp(): void
{
$cred = $this->makeCredential();
$signer = new Signer();
$cms = $signer->sign(
'document bytes',
$cred['cert_der'],
$cred['key'],
[],
new Config(Config::PROFILE_LEGACY),
self::SIGNING_TIME,
);
$this->assertStringNotContainsString($this->timestampOidDer(), $cms);
// The legacy (ISO 32000-1) profile keeps the CMS signing-time attribute.
$this->assertStringContainsString($this->signingTimeOidDer(), $cms);
}
public function testSignBbProfileHasNoSignatureTimestamp(): void
{
$cred = $this->makeCredential();
$signer = new Signer();
$cms = $signer->sign(
'document bytes',
$cred['cert_der'],
$cred['key'],
[],
new Config(Config::PROFILE_PADES_B_B),
self::SIGNING_TIME,
);
$this->assertStringNotContainsString($this->timestampOidDer(), $cms);
// PAdES-BASELINE forbids the CMS signing-time attribute (ETSI EN 319 142-1);
// the signing time is carried by the /M signature dictionary entry instead.
$this->assertStringNotContainsString($this->signingTimeOidDer(), $cms);
}
public function testSignBtProfileEmbedsSignatureTimestamp(): void
{
$cred = $this->makeCredential();
$token = $this->asn1->encodeSequence($this->asn1->encodeOctetString('rfc3161-token-body'));
$captured = null;
$transport = function (string $request) use (&$captured, $token): string {
$captured = $request;
return $this->timestampResponse($token);
};
$signer = new Signer();
$cms = $signer->sign(
'document bytes',
$cred['cert_der'],
$cred['key'],
[],
new Config(Config::PROFILE_PADES_B_T),
self::SIGNING_TIME,
new TimestampClient(new TimestampConfig('https://tsa.example.org')),
$transport,
);
// The transport received a DER TimeStampReq (SEQUENCE).
$this->assertIsString($captured);
$this->assertSame("\x30", $captured[0]);
// The CMS carries the signature-timestamp attribute and the returned token bytes.
$this->assertStringContainsString($this->timestampOidDer(), $cms);
$this->assertStringContainsString($token, $cms);
}
public function testSignBtProfileRequiresTimestampClient(): void
{
$cred = $this->makeCredential();
$signer = new Signer();
$this->expectException(Exception::class);
$signer->sign(
'document bytes',
$cred['cert_der'],
$cred['key'],
[],
new Config(Config::PROFILE_PADES_B_T),
self::SIGNING_TIME,
);
}
public function testSignBtProfileRequiresTransport(): void
{
$cred = $this->makeCredential();
$signer = new Signer();
$this->expectException(Exception::class);
$signer->sign(
'document bytes',
$cred['cert_der'],
$cred['key'],
[],
new Config(Config::PROFILE_PADES_B_LTA),
self::SIGNING_TIME,
new TimestampClient(new TimestampConfig('https://tsa.example.org')),
null,
);
}
public function testCollectValidationMaterialGathersCertsOcspAndCrls(): void
{
$ltvPem = (string) \file_get_contents(__DIR__ . '/data/ltv_cert.pem');
$caPem = (string) \file_get_contents(__DIR__ . '/data/ocsp_ca.pem');
$ocspCalls = [];
$ocspTransport = static function (string $url, string $request) use (&$ocspCalls): string {
$ocspCalls[] = ['url' => $url, 'request' => $request];
return 'OCSP-RESPONSE';
};
$crlTransport = static fn(string $url): string => 'CRL-' . $url;
$signer = new Signer();
$material = $signer->collectValidationMaterial([$ltvPem, $caPem], $ocspTransport, $crlTransport);
// Both certificates are collected as DER.
$this->assertCount(2, $material['certs']);
// The leaf's single OCSP responder was queried; the CA has none.
$this->assertCount(1, $ocspCalls);
$firstOcspCall = $ocspCalls[0] ?? null;
if (!\is_array($firstOcspCall)) {
$this->fail('Expected a captured OCSP call');
}
$this->assertSame('http://ocsp.example.org/r', $firstOcspCall['url']);
$this->assertSame("\x30", $firstOcspCall['request'][0]);
$this->assertSame(['OCSP-RESPONSE'], $material['ocsp']);
// The leaf carries two distinct CRL distribution points.
$this->assertSame(
['CRL-http://crl.example.org/root.crl', 'CRL-http://crl2.example.org/root.crl'],
$material['crls'],
);
}
public function testCollectValidationMaterialSkipsRevocationWithoutTransports(): void
{
$ltvPem = (string) \file_get_contents(__DIR__ . '/data/ltv_cert.pem');
$caPem = (string) \file_get_contents(__DIR__ . '/data/ocsp_ca.pem');
$signer = new Signer();
$material = $signer->collectValidationMaterial([$ltvPem, $caPem]);
$this->assertCount(2, $material['certs']);
$this->assertSame([], $material['ocsp']);
$this->assertSame([], $material['crls']);
}
public function testCollectValidationMaterialDeduplicatesCertificates(): void
{
$ltvPem = (string) \file_get_contents(__DIR__ . '/data/ltv_cert.pem');
$signer = new Signer();
$material = $signer->collectValidationMaterial([$ltvPem, $ltvPem]);
$this->assertCount(1, $material['certs']);
}
public function testCollectValidationMaterialRejectsInvalidPem(): void
{
$signer = new Signer();
$this->expectException(Exception::class);
$signer->collectValidationMaterial(['-----BEGIN CERTIFICATE-----@@-----END CERTIFICATE-----']);
}
/**
* DER of the id-aa-signatureTimeStampToken OID, used as a presence probe.
*/
private function timestampOidDer(): string
{
return $this->asn1->encodeObjectIdentifier(self::OID_SIGNATURE_TIMESTAMP);
}
/**
* DER of the CMS signing-time OID, used as a presence probe.
*/
private function signingTimeOidDer(): string
{
return $this->asn1->encodeObjectIdentifier(self::OID_SIGNING_TIME);
}
/**
* Build a minimal DER RFC 3161 TimeStampResp wrapping the given token.
*/
private function timestampResponse(string $tstDer): string
{
$status = $this->asn1->encodeSequence($this->asn1->encodeInteger(0));
return $this->asn1->encodeSequence($status . $tstDer);
}
/**
* Generate an RSA private key and a matching self-signed certificate.
*
* @return array{key: OpenSSLAsymmetricKey, cert_pem: string, cert_der: string}
*/
private function makeCredential(): array
{
$config = [
'config' => __DIR__ . '/../openssl.cnf',
'digest_alg' => 'sha256',
'private_key_bits' => 2048,
'private_key_type' => OPENSSL_KEYTYPE_RSA,
];
$key = \openssl_pkey_new($config);
if (!$key instanceof OpenSSLAsymmetricKey) {
$this->markTestSkipped('RSA key generation is not available');
}
$csr = \openssl_csr_new(['commonName' => 'tc-lib-pdf-sign signer'], $key, $config);
if (!$csr instanceof \OpenSSLCertificateSigningRequest) {
$this->markTestSkipped('CSR generation failed');
}
$cert = \openssl_csr_sign($csr, null, $key, 365, $config);
if (!$cert instanceof \OpenSSLCertificate) {
$this->markTestSkipped('Certificate signing failed');
}
$certPem = '';
\openssl_x509_export($cert, $certPem);
$stripped = (string) \preg_replace('/-----[^-]+-----|\s+/', '', $certPem);
$der = \base64_decode($stripped, true);
if ($der === false) {
$this->fail('Invalid PEM');
}
return ['key' => $key, 'cert_pem' => $certPem, 'cert_der' => $der];
}
}
@@ -0,0 +1,236 @@
<?php
declare(strict_types=1);
/**
* ClientTest.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 Test\Timestamp;
use Com\Tecnick\Pdf\Sign\Cms\Asn1;
use Com\Tecnick\Pdf\Sign\Exception;
use Com\Tecnick\Pdf\Sign\Timestamp\Client;
use Com\Tecnick\Pdf\Sign\Timestamp\Config;
use PHPUnit\Framework\TestCase;
/**
* Timestamp Client Test
*
* @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 ClientTest extends TestCase
{
private Asn1 $asn1;
protected function setUp(): void
{
$this->asn1 = new Asn1();
}
private function client(bool $nonce = false, string $policyOid = '', string $hash = 'sha256'): Client
{
return new Client(new Config(
host: 'https://tsa.example.org',
hashAlgorithm: $hash,
policyOid: $policyOid,
nonceEnabled: $nonce,
));
}
/**
* Build a minimal valid TimeStampResp wrapping the given content.
*
* @param int<0, max> $statusCode PKIStatus value.
*/
private function response(int $statusCode, string $content): string
{
return $this->asn1->encodeSequence(
$this->asn1->encodeSequence($this->asn1->encodeInteger($statusCode)) . $content,
);
}
private function sampleToken(): string
{
return $this->asn1->encodeSequence($this->asn1->encodeObjectIdentifier('1.2.840.113549.1.7.2'));
}
public function testHashAlgorithmOid(): void
{
$client = $this->client();
$this->assertSame('2.16.840.1.101.3.4.2.1', $client->hashAlgorithmOid('sha256'));
$this->assertSame('2.16.840.1.101.3.4.2.2', $client->hashAlgorithmOid('sha384'));
$this->assertSame('2.16.840.1.101.3.4.2.3', $client->hashAlgorithmOid('sha512'));
}
public function testHashAlgorithmOidRejectsUnknown(): void
{
$this->expectException(Exception::class);
$this->client()->hashAlgorithmOid('sha1');
}
public function testBuildRequestStructure(): void
{
$req = $this->client()->buildRequest('payload');
$offset = 0;
$root = $this->asn1->readTlv($req, $offset);
$this->assertSame(0x30, $root['tag']);
$this->assertSame(\strlen($req), $offset);
$inner = 0;
$version = $this->asn1->readTlv($root['value'], $inner);
$this->assertSame(0x02, $version['tag']);
$this->assertSame(1, $this->asn1->decodeInteger($version['value']));
$messageImprint = $this->asn1->readTlv($root['value'], $inner);
$this->assertSame(0x30, $messageImprint['tag']);
$certReq = $this->asn1->readTlv($root['value'], $inner);
$this->assertSame(0x01, $certReq['tag']);
$this->assertSame("\xFF", $certReq['value']);
// Nothing follows certReq when no policy and no nonce are present.
$this->assertSame(\strlen($root['value']), $inner);
// The message imprint carries the SHA-256 digest of the input.
$miOffset = 0;
$algId = $this->asn1->readTlv($messageImprint['value'], $miOffset);
$this->assertSame(0x30, $algId['tag']);
$digest = $this->asn1->readTlv($messageImprint['value'], $miOffset);
$this->assertSame(0x04, $digest['tag']);
$this->assertSame(\hash('sha256', 'payload', true), $digest['value']);
}
public function testBuildRequestIncludesPolicyOid(): void
{
$req = $this->client(policyOid: '1.2.3.4')->buildRequest('x');
$offset = 0;
$root = $this->asn1->readTlv($req, $offset);
$inner = 0;
$this->asn1->readTlv($root['value'], $inner); // version
$this->asn1->readTlv($root['value'], $inner); // messageImprint
$policy = $this->asn1->readTlv($root['value'], $inner);
$this->assertSame(0x06, $policy['tag']);
$this->assertSame($this->asn1->encodeObjectIdentifier('1.2.3.4'), $policy['raw']);
}
public function testBuildRequestIncludesNonce(): void
{
$req = $this->client(nonce: true)->buildRequest('x');
$offset = 0;
$root = $this->asn1->readTlv($req, $offset);
$inner = 0;
$this->asn1->readTlv($root['value'], $inner); // version
$this->asn1->readTlv($root['value'], $inner); // messageImprint
$nonce = $this->asn1->readTlv($root['value'], $inner);
$this->assertSame(0x02, $nonce['tag']);
$certReq = $this->asn1->readTlv($root['value'], $inner);
$this->assertSame(0x01, $certReq['tag']);
}
public function testParseResponseReturnsToken(): void
{
$token = $this->sampleToken();
$this->assertSame($token, $this->client()->parseResponse($this->response(0, $token)));
// status 1 (granted with mods) is also accepted
$this->assertSame($token, $this->client()->parseResponse($this->response(1, $token)));
}
public function testParseResponseRejectsEmpty(): void
{
$this->expectException(Exception::class);
$this->client()->parseResponse('');
}
public function testParseResponseRejectsNonSequenceRoot(): void
{
$this->expectException(Exception::class);
$this->client()->parseResponse($this->asn1->encodeInteger(0));
}
public function testParseResponseRejectsInvalidStatusStructure(): void
{
$bad = $this->asn1->encodeSequence($this->asn1->encodeInteger(0) . $this->sampleToken());
$this->expectException(Exception::class);
$this->client()->parseResponse($bad);
}
public function testParseResponseRejectsNonIntegerStatus(): void
{
$bad = $this->asn1->encodeSequence(
$this->asn1->encodeSequence($this->asn1->encodeOctetString('x')) . $this->sampleToken(),
);
$this->expectException(Exception::class);
$this->client()->parseResponse($bad);
}
public function testParseResponseRejectsRejectedStatus(): void
{
$this->expectException(Exception::class);
$this->client()->parseResponse($this->response(2, $this->sampleToken()));
}
public function testParseResponseRejectsMissingToken(): void
{
$noToken = $this->asn1->encodeSequence($this->asn1->encodeSequence($this->asn1->encodeInteger(0)));
$this->expectException(Exception::class);
$this->client()->parseResponse($noToken);
}
public function testParseResponseRejectsNonSequenceToken(): void
{
$bad = $this->asn1->encodeSequence(
$this->asn1->encodeSequence($this->asn1->encodeInteger(0)) . $this->asn1->encodeInteger(5),
);
$this->expectException(Exception::class);
$this->client()->parseResponse($bad);
}
public function testRequestTokenUsesTransport(): void
{
$token = $this->sampleToken();
$response = $this->response(0, $token);
$captured = '';
$transport = static function (string $request) use (&$captured, $response): string {
$captured = $request;
return $response;
};
$result = $this->client()->requestToken('payload', $transport);
$this->assertSame($token, $result);
// The transport received a well-formed DER request.
$offset = 0;
$root = $this->asn1->readTlv($captured, $offset);
$this->assertSame(0x30, $root['tag']);
}
public function testRequestTokenRejectsNonStringTransportResult(): void
{
$transport = static fn(string $request): int => \strlen($request);
$this->expectException(Exception::class);
$this->client()->requestToken('payload', $transport);
}
}
@@ -0,0 +1,78 @@
<?php
declare(strict_types=1);
/**
* ConfigTest.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 Test\Timestamp;
use Com\Tecnick\Pdf\Sign\Exception;
use Com\Tecnick\Pdf\Sign\Timestamp\Config;
use PHPUnit\Framework\TestCase;
/**
* Timestamp Config Test
*
* @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 ConfigTest extends TestCase
{
public function testDefaults(): void
{
$cfg = new Config(host: 'https://tsa.example.org/tsr');
$this->assertSame('https://tsa.example.org/tsr', $cfg->host);
$this->assertSame('sha256', $cfg->hashAlgorithm);
$this->assertSame('', $cfg->policyOid);
$this->assertTrue($cfg->nonceEnabled);
$this->assertSame(5, $cfg->timeout);
$this->assertTrue($cfg->verifyPeer);
}
public function testAcceptsValidPolicyOid(): void
{
$cfg = new Config(host: 'https://tsa.example.org', policyOid: '1.2.3.4.5');
$this->assertSame('1.2.3.4.5', $cfg->policyOid);
}
public function testEmptyHostThrows(): void
{
$this->expectException(Exception::class);
new Config(host: '');
}
public function testInvalidHashAlgorithmThrows(): void
{
$this->expectException(Exception::class);
new Config(host: 'https://tsa.example.org', hashAlgorithm: 'md5');
}
public function testInvalidPolicyOidThrows(): void
{
$this->expectException(Exception::class);
new Config(host: 'https://tsa.example.org', policyOid: 'not-an-oid');
}
public function testInvalidTimeoutThrows(): void
{
$this->expectException(Exception::class);
new Config(host: 'https://tsa.example.org', timeout: 0);
}
}
@@ -0,0 +1,25 @@
-----BEGIN CERTIFICATE-----
MIIEIjCCAwqgAwIBAgIUWNwtf35SSDKt+d9LEkCR8OJsr4QwDQYJKoZIhvcNAQEL
BQAwQTELMAkGA1UEBhMCSVQxFDASBgNVBAoMC1RlY25pY2suY29tMRwwGgYDVQQD
DBN0Yy1saWItcGRmLXNpZ24gbHR2MB4XDTI2MDcxNTE1MjA0NFoXDTM2MDcxMjE1
MjA0NFowQTELMAkGA1UEBhMCSVQxFDASBgNVBAoMC1RlY25pY2suY29tMRwwGgYD
VQQDDBN0Yy1saWItcGRmLXNpZ24gbHR2MIIBIjANBgkqhkiG9w0BAQEFAAOCAQ8A
MIIBCgKCAQEAg4XPQlnyr4QdxRDUBUHLZVC+wEyOtkmSX9RAGISr8ytuY8bFh3Tc
coLT8pdMYGDg3ZxV+MJ1wUAmv2b8pDl2/ve22ZdldrPR06kUQcl/9b/sxWcEuDyV
viwZpXFdkX3minGkbsgoaun2zPvSZS+r77XCXhXFnx70kgQtg0rSuiCVLoPL7XiG
K/vK9ZqcDMQhxyUyqmJxKdvqgmM0v7TjGF+A6Mo65Sc+8rYRa50jfpDf4+3vPAJu
AZktlnhSz5ZAhg6MSDR/qmOJQB0wbHVqrTr/H/Vpxl4Hm4+HvSl+71wxr+eE1ENR
VM7OuBRz0f4FmPpFC1u54xgTvFDz0vDe2wIDAQABo4IBEDCCAQwwHQYDVR0OBBYE
FNGM/XjoY0lALUKtwHTkWY3c6nZ6MB8GA1UdIwQYMBaAFNGM/XjoY0lALUKtwHTk
WY3c6nZ6MA8GA1UdEwEB/wQFMAMBAf8wXwYIKwYBBQUHAQEEUzBRMCUGCCsGAQUF
BzABhhlodHRwOi8vb2NzcC5leGFtcGxlLm9yZy9yMCgGCCsGAQUFBzAChhxodHRw
Oi8vY2EuZXhhbXBsZS5vcmcvY2EuY3J0MFgGA1UdHwRRME8wJaAjoCGGH2h0dHA6
Ly9jcmwuZXhhbXBsZS5vcmcvcm9vdC5jcmwwJqAkoCKGIGh0dHA6Ly9jcmwyLmV4
YW1wbGUub3JnL3Jvb3QuY3JsMA0GCSqGSIb3DQEBCwUAA4IBAQAY7wLUL9YwBdN/
sVtGFDd264QBANh18iKhv9hybvAPA2xcI7vxcW8voiL3ad1IUoUJ+TZktTq/VF52
T7dA9lml5vhospkTlRpG8yRBLYm+p2KuIxAKcuIIxe8kzygVYc3eyWJ8jL2ExcC+
H3Y4W0/5S72qvOUL3avI0s/3ru/bPR5mb55CbNLLlqS+GViU3BqLlP7JwM/DRWPu
k4tH5cSfzGZp9pu9wtXr4sbGF5MPVz8IjwzGvHOR93TtuiKWs52eI8pZ/fVQo2y7
tANdIGmFNUWUrKrtwUHLO+fdQ4p0sqj33UuqVdj04iIubyIdU7Ef7H0kkeJf48/y
D7BYPfJl
-----END CERTIFICATE-----
+21
View File
@@ -0,0 +1,21 @@
-----BEGIN CERTIFICATE-----
MIIDazCCAlOgAwIBAgIUOTFQcwfHy5Q5NgSG2L1p7A9oPWgwDQYJKoZIhvcNAQEL
BQAwRTELMAkGA1UEBhMCSVQxFDASBgNVBAoMC1RlY25pY2suY29tMSAwHgYDVQQD
DBd0Yy1saWItcGRmLXNpZ24gcm9vdCBDQTAeFw0yNjA3MTUxNDU1MzVaFw00NjA3
MTAxNDU1MzVaMEUxCzAJBgNVBAYTAklUMRQwEgYDVQQKDAtUZWNuaWNrLmNvbTEg
MB4GA1UEAwwXdGMtbGliLXBkZi1zaWduIHJvb3QgQ0EwggEiMA0GCSqGSIb3DQEB
AQUAA4IBDwAwggEKAoIBAQDAACX+3AyfPuwOXtzL2l6jVCu3UFVEw6FFqMxFZrpl
QsG1VfQLCIaotd6+UIypRV9hq67Au3n+naLZnLtmCuyiR0EbxnQYf67U4QZak+cq
yKwndEqROMyqZYyf4IYyVyW3E4W/mT9LoD7ISSbPvbAWv72cvBgi2OKUtlxUZehE
V0qVOEQck3ZOwzlggRGZLMnWi55+dTb1fP+61LU3aWBiR1FS0Nxg5PRSC4S6trVZ
2HsGMKtxEKD1veYY6sGcH4fqSA3AI6+AMrgbtp6HsfgnPw+1Q+zt0yEK+E//8Kpx
P9VFMj8/ONT9BVERqihwX+17km5o5/kd01ZzstebvElVAgMBAAGjUzBRMB0GA1Ud
DgQWBBQqzMd3ZI35/UK3VNIFWjzwgJVfTzAfBgNVHSMEGDAWgBQqzMd3ZI35/UK3
VNIFWjzwgJVfTzAPBgNVHRMBAf8EBTADAQH/MA0GCSqGSIb3DQEBCwUAA4IBAQBt
QYNoek7H5NrID9Csy2ad9RoFpMZeGQr5c0Z8SlBd/CbD9DP6K5UsRb+w1C6XGenl
CWxa0a0FOXqD8pP7foaGd1jieQ3/BAtiBrMn7ulPbJNlLwt0Qxt9FKxihjvOjmiO
wlmLBSJWWh7eeUT0RVCpIksse2TCHuhGcvMLhH/gt0WwIe9fNWikzD4X7Cv1jldF
50OvuSnXdp3ThQ6DDKb/fWDeYJUtXD4Nl/NH34ReV9HJOL8sdpsnWxZb49SkbKKs
3ww1F4Pceiw4BOOUGRyVdizUQmwucE0T/98458acCmCShXdEneS+O7hvFXzrvmgm
hLEVWFk9n7mJuNHTiehZ
-----END CERTIFICATE-----
@@ -0,0 +1,20 @@
-----BEGIN CERTIFICATE-----
MIIDVzCCAj+gAwIBAgIUBNC7KlNLnV+s3gpiiwug3I64MEMwDQYJKoZIhvcNAQEL
BQAwRTELMAkGA1UEBhMCSVQxFDASBgNVBAoMC1RlY25pY2suY29tMSAwHgYDVQQD
DBd0Yy1saWItcGRmLXNpZ24gcm9vdCBDQTAeFw0yNjA3MTUxNDU1MzVaFw0zNjA3
MTIxNDU1MzVaMEIxCzAJBgNVBAYTAklUMRQwEgYDVQQKDAtUZWNuaWNrLmNvbTEd
MBsGA1UEAwwUdGMtbGliLXBkZi1zaWduIGxlYWYwggEiMA0GCSqGSIb3DQEBAQUA
A4IBDwAwggEKAoIBAQCzQUS2pvzQ7P1LBwWEgU5eyT3dRkHeTkcmcmj49IaGKREM
boSywMTqk+LmZYqXrSB90OVIOOU0X7zXJTWta7u97GwFYPscZwS7tEycY8Vrwpau
Y+Su3KpfwN9I5re7RgCW/N3u+voZ4BpOGkhoUgoN3GF87jf/eVq3Uy1FqVOizG5J
aZzflElpg+SyuuRz4H0x+PsyftMDDG+WJoaWV/KIk25+QeiRgqmBwi3bSokeITsl
E5glr9fSktThyoHa/u5Yn1Wv8QqGfOnOxxusHPM/WEZ8JdvnzBXuxVi9xpiBwfq/
FnyAVN8W5OBd91cWR7xTN5aMmzItIgvmYRJ4vuXhAgMBAAGjQjBAMB0GA1UdDgQW
BBQxGlEGCBM5nkqll8ti1cOLI4SD3zAfBgNVHSMEGDAWgBQqzMd3ZI35/UK3VNIF
WjzwgJVfTzANBgkqhkiG9w0BAQsFAAOCAQEAaiQbyzIGNu2qptaKEm/pMBqhuJ/e
S69RfW/ZF3gDwRUdCEV8MCqpX+goWCZwjj8vCBTIYk6sZSpN0S6TQQ19h2U3qoyK
WqKMxZQo69AQ8/O4I6pVuBQ8u4H9aCEkrq0WD+8bNNSYlRFWX49dZ0CmIRpcWBl3
Qwr37FiSdFh8yRdX7HU2yMXtih9eL/teiGxtB8zuwh/UlpamwsldIkkv/45aoRD1
/TV79ltJDEbV1wVPxkccVQbw7PV3DfRb1KZIb3oXM1LMWrCa25dOR8fyqkhdz+f0
dqz41uIJzONQQu7EzWWBYP1XwKsTjP6PHZR6vJ/s+aty0lpQuUCT0HtFwg==
-----END CERTIFICATE-----