generated from jric11/baseProject
292 lines
9.5 KiB
PHP
292 lines
9.5 KiB
PHP
|
|
<?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];
|
||
|
|
}
|
||
|
|
}
|