* @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 * @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]; } }