Files

292 lines
9.5 KiB
PHP
Raw Permalink Normal View History

2026-08-30 22:02:02 +00:00
<?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];
}
}