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