generated from jric11/baseProject
Initial commit
This commit is contained in:
@@ -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,
|
||||
];
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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-----
|
||||
@@ -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-----
|
||||
Reference in New Issue
Block a user