Long-Term Validation (LTV)

Introduction

The SetaPDF-Signer component comes with functionalities to gather revocation information of X509 certificates, PDF signatures and timestamps.

Adding these information to a CMS/signature container or to the PDF document a verification of the signature will be possible over the life-time of involved certificates. This will end in LTV (Long-Term Validation) enabled PDF signatures and timestamps.

Implemented features

  1. Access to X509 certificates (including several extensions such as AIA, or cRLDistributionPoints ).
  2. Certificate chain builder.
  3. Handling of certificate collection.
  4. Parsing and creation of certificate bundles.
  5. Access to information of CMS signature containers.
  6. Parsing of "certs-only" containers.
  7. OCSP client (including creation and interpretation of requests and responses)
  8. CRL resolver (including interpretation).
  9. Access to timestamp signatures and tokens.
  10. Methods to create packages of validation related information based on a PDF signature field name or a X509 certificate.
  11. Methods to verify the integrity of a PDF signatures.
  12. Allow the usage of PSR-16 cache for external resolved information (CRLs, intermediate certificates)
  13. Signature verification of all involved data (RSASSA-PKCS1-v1_5 by native OpenSSL PHP functions; RSASSA-PSS if phpseclib is installed).

What's currently NOT implemented

  • "Name Constraints" in certificate path building process
    (https://tools.ietf.org/html/rfc4158#section-3.5.8)
  • "Policy Processing" in certificate path building process
    (https://tools.ietf.org/html/rfc4158#section-3.5.13)

Because of these missing pieces we do not promote this functionality as a full validation engine.

How to get validation related information

 As we need to do this several times we start with creating an instance of a X509 certificate instance. It's that simple:

PHP
use SetaPDF_Signer_X509_Certificate as Certificate;
    
$certificateString = '...PEM or DER encoded bytes of the certificate...';
$certificate = new Certificate($certificateString);
    
// if the certificate is located in a file:
$certificate = Certificate::fromFile('path/to/cert.pem');

// or let the method decide how to interpret the argument:
$certificate = Certificate::fromFileOrString('path/to/cert.pem');

As everything in PKI is based on trust you will need to have a certificate bundle of trusted certificates. This bundle needs to include at least the root certificate of the certificate path of the certificate for that you want to gather validation related information. Intermediate certificates, if possible, will be resolved by the AIA extension of certificates.

You can read in such bundle into a certifcate Collection this way:

PHP
use SetaPDF_Signer_Pem as Pem;
use SetaPDF_Signer_X509_Collection as Collection;

$trustedCertificates = new Collection(Pem::extractFromFile('path/to/cacert.pem'));

It is also possible to build the Collection instance manually if you have the root certifcates in single files or if you want to add several bundles:

PHP
$trustedCertificates = new Collection();
$trustedCertificates
    ->add(Certificate::fromFile('path/to/root/certA.pem'))
    ->add(Certificate::fromFile('path/to/root/certB.pem'));
    ->add(Pem::extractFromFile('path/to/cacert.pem'));

Collection instances can also be stapled.

If you want to implement your own logic to resolve certificates (e.g. through a database) you can implement this in your own class which implements the SetaPDF_Signer_X509_CollectionInterface interface.

To gather validation related information for the certifcate in $certificate we simply have to use the Collector class now:

PHP
use SetaPDF_Signer_ValidationRelatedInfo_Collector as Collector;

$collector = new Collector($trustedCertificates);
$vriResult = $collector->getByCertificate($certificate);

If the certificate path cannot be build an SetaPDF_Signer_ValidationRelatedInfo_Exception is thrown.

The returned value of getByCertificate() is an instance of SetaPDF_Signer_ValidationRelatedInfo_Result.

If you want to do a simple CMS signature you can embed this VRI (validation related information) data into the signature container this way:

PHP
$module->setExtraCertificates($vriResult->getCertificates());
foreach ($vriResult->getOcspResponses() as $ocspResponse) {
    $module->addOcspResponse($ocspResponse);
}
foreach ($vriResult->getCrls() as $crl) {
    $module->addCrl($crl);
}

This will already result in an LTV (Long-Term Validation) enabled signature in Adobe Acrobat/Reader.

But if you add a timestamp token to the signature or if you do a document timestamp signature it is impossible to embed validation related information into the CMS structure.

These information needs to be embedded afterwards through an additional incremental update of the signed PDF document with a Document Security Store (DSS). Instead of gathering the VRI based on the certificate the Collector class offers another method which will gather the information based on a signature field name. As we need an intermediate version of the signed PDF we are going to use different writer instances:  

PHP
use SetaPDF_Signer_DocumentSecurityStore as DocumentSecurityStore;

$writer = new SetaPDF_Core_Writer_File('signed-ltv.pdf');

$tmpWriter = new SetaPDF_Core_Writer_TempFile();
$document = SetaPDF_Core_Document::loadByFilename('not-signed.pdf', $tmpWriter);

$signer = new SetaPDF_Signer($document);
$fieldName = $signer->addSignatureField()->getQualifiedName();
$signer->setSignatureFieldName($fieldName);

$module = new SetaPDF_Signer_Signature_Module_Cms();
...
$signer->sign();

// let's gather and add the validation data
$document = SetaPDF_Core_Document::loadByFilename($tmpWriter->getPath(), $writer);

$collector = new Collector($trustedCertificates);
$vriResult = $collector->getByFieldName($document, $fieldName);

$dss = new DocumentSecurityStore($document);
$dss->addValidationRelatedInfoByFieldName(
    $fieldName,
    $vriResult->getCrls(),
    $vriResult->getOcspResponses(),
    $vriResult->getCertificates()
);

$document->save()->finish();

If you want to combine both, validation information in the CMS container and in the DSS, you can pass the result of the first getByCertificate() to the next getByFieldName() call:

PHP
$vriResult = $collector->getByFieldName(
    $document, $fieldName, Collector::SOURCE_OCSP_OR_CRL, null, null, $vriResult
);

Doing this the validation related information of the first call will not be recreated but will be used to resolve the
missing validation information for the certificates of the timestamp.

Logging

To get internal details about the process, you can get a logger instance from the Collector instance:

PHP
$logger = $collector->getLogger();
foreach ($logger->getLogs() as $log) {
    echo str_repeat(' ', $log->getDepth() * 4) . $log . "\n";
}

Performance

The Collector class will need to access external resources which could lead into performance problems. These resources are requests to OCSP servers, loading of issuer certificates and CRL files.

All of these resources can be cached by passing cache instances implementing PSR-16 to a Collector instance:

PHP
$issuerCache = new Psr16CompatibleCacheInstance();
$collector->setIssuerCache($issuerCache);

$ocspCache = new Psr16CompatibleCacheInstance();
$collector->setOcspCache($ocspCache);

$crlCache = new Psr16CompatibleCacheInstance();
$collector->setCrlCache($crlCache);

For issuer certificates and CRLs the cache key for the requested data is the hex encoded md5 hash of the requested URI. For OCSP responses the hash is build by combining the URI and a digest of the certificate. So all caches can share a single cache instance without problems.

The TTL of the cache items will be calculate based on the information of the items itself. A certificate will get the TTL until it expires. A OCSP response or CRL will get the TTL by its next update information.

Please see here for packages providing psr/simple-cache-implementation.

Information Resolvers

By default information such as issuer certificates or CRLs are resolved by the HTTP protocol using curl_* functions of PHP.

If you need to handle other protocols or if you need to pass individual curl options to the instance, you need to create the SetaPDF_Signer_InformationResolver_Manager instance manually and pass the resolver instances manually, too. A resolver instance needs to implement the SetaPDF_Signer_InformationResolver_ResolverInterface interface.

PHP
use SetaPDF_Signer_InformationResolver_Manager as Manager;
use SetaPDF_Signer_InformationResolver_HttpCurlResolver as HttpCurlResolver;
use SetaPDF_Signer_InformationResolver_ResolverInterface as ResolverInterface;

class LdapResolver implements ResolverInterface
{
    public function accepts($uri)
    {
        $schema = strtolower(parse_url($uri, PHP_URL_SCHEME));
        return $schema === 'ldap';
    }
    
    public function resolve($uri)
    {
        // your code which resolves the URI via LDAP
        //...
        return [$contentType, $response];
    }
}

$manager = new Manager();
$manager->addResolver(new HttpCurlResolver());
$manager->addResolver(new LdapResolver());

$vri->setInformationResolverManager($manager);
//...

Examples

Following some examples which will give you some more understanding about the whole workflow: 

Add LTV information to CMS signature

We start with a simple example that gathers revocation information in real-time for a CMS signature: 

PHP
<?php

use SetaPDF_Signer_X509_Certificate as Certificate;
use SetaPDF_Signer_Pem as Pem;
use SetaPDF_Signer_X509_Collection as Collection;
use SetaPDF_Signer_ValidationRelatedInfo_Collector as Collector;
use SetaPDF_Signer_DocumentSecurityStore as DocumentSecurityStore;

$writer = new \SetaPDF_Core_Writer_Http('signed.pdf');
$document = \SetaPDF_Core_Document::loadByFilename('in.pdf', $writer);

$signer = new \SetaPDF_Signer($document);
$signer->setSignatureContentLength(26000);

openssl_pkcs12_read(
    file_get_contents(__DIR__ . '/path/to/certificate.pfx'),
    $certData,
    'the-password'
);

$module = new \SetaPDF_Signer_Signature_Module_Pades();
$module->setCertificate($certData['cert']);
$module->setPrivateKey($certData['pkey']);

// Create a collection of trusted certificats:
$trustedCertificates = new Collection(Pem::extractFromFile('path/to/cacert.pem');
// Create a collector instance
$collector = new Collector($trustedCertificates);
// Create a Certificate instance
$certificate = new Certificate($certData['cert']);
// Collect revocation information for this certificate
$vriData = $collector->getByCertificate($certificate);

// now add these information to the CMS container
$module->setExtraCertificates($vriData->getCertificates());
foreach ($vriData->getOcspResponses() as $ocspResponse) {
    $module->addOcspResponse($ocspResponse);
}
foreach ($vriData->getCrls() as $crl) {
    $module->addCrl($crl);
}

$signer->sign($module);

Add LTV information to Document Security Store (DSS) only

Same example as above but the revocation information is added afterwards. For demonstration we also add a timestamp to the signature:

PHP
<?php

use SetaPDF_Signer_X509_Certificate as Certificate;
use SetaPDF_Signer_Pem as Pem;
use SetaPDF_Signer_X509_Collection as Collection;
use SetaPDF_Signer_ValidationRelatedInfo_Collector as Collector;
use SetaPDF_Signer_DocumentSecurityStore as DocumentSecurityStore;

$writer = new \SetaPDF_Core_Writer_Http('signed.pdf');

$tempWriter = new \SetaPDF_Core_Writer_String();
$document = \SetaPDF_Core_Document::loadByFilename('in.pdf', $tempWriter);

$signer = new \SetaPDF_Signer($document);
$signer->setSignatureContentLength(16000);

openssl_pkcs12_read(
    file_get_contents(__DIR__ . '/path/to/certificate.pfx'),
    $certData,
    'the-password'
);

$module = new \SetaPDF_Signer_Signature_Module_Pades();
$module->setCertificate($certData['cert']);
$module->setPrivateKey($certData['pkey']);

$tsModule = new \SetaPDF_Signer_Timestamp_Module_Rfc3161_Curl('http://example.com/tsa');
$tsModule->setDigest(\SetaPDF_Signer_Digest::SHA_256);
$signer->setTimestampModule($tsModule);

// let's keep the field name for later usage
$fieldName = $signer->addSignatureField()->getQualifiedName();
$signer->setSignatureFieldName($fieldName);

$signer->sign($module);

// Up to here the signature has no LTV information embedded.
// Now load the intermediate document, resolve the informaiton and embedded them

$document = \SetaPDF_Core_Document::loadByString($tempWriter, $writer);

// Create a collection of trusted certificats:
$trustedCertificates = new Collection(Pem::extractFromFile('path/to/cacert.pem');
// Create a collector instance
$collector = new Collector($trustedCertificates);

$vriData = $collector->getByFieldName($document, $fieldName);

$dss = new DocumentSecurityStore($document);
$dss->addValidationRelatedInfoByFieldName(
    $fieldName,
    $vriData->getCrls(),
    $vriData->getOcspResponses(),
    $vriData->getCertificates()
);

$document->save()->finish();

Add LTV information to CMS and Document Security Store (DSS)

The next demo will add the validation related information to both the CMS container and a DSS. 

PHP
<?php

use SetaPDF_Signer_X509_Certificate as Certificate;
use SetaPDF_Signer_Pem as Pem;
use SetaPDF_Signer_X509_Collection as Collection;
use SetaPDF_Signer_ValidationRelatedInfo_Collector as Collector;
use SetaPDF_Signer_DocumentSecurityStore as DocumentSecurityStore;

$writer = new \SetaPDF_Core_Writer_Http('signed.pdf');

$tempWriter = new \SetaPDF_Core_Writer_String();
$document = \SetaPDF_Core_Document::loadByFilename('in.pdf', $tempWriter);

$signer = new \SetaPDF_Signer($document);
$signer->setSignatureContentLength(16000);

openssl_pkcs12_read(
    file_get_contents(__DIR__ . '/path/to/certificate.pfx'),
    $certData,
    'the-password'
);

$module = new \SetaPDF_Signer_Signature_Module_Pades();
$module->setCertificate($certData['cert']);
$module->setPrivateKey($certData['pkey']);

// Create a collection of trusted certificats:
$trustedCertificates = new Collection(Pem::extractFromFile('path/to/cacert.pem');
// Create a collector instance
$collector = new Collector($trustedCertificates);
// Create a Certificate instance
$certificate = new Certificate($certData['cert']);
// Collect revocation information for this certificate
$vriData = $collector->getByCertificate($certificate);

// now add these information to the CMS container
$module->setExtraCertificates($vriData->getCertificates());
foreach ($vriData->getOcspResponses() as $ocspResponse) {
    $module->addOcspResponse($ocspResponse);
}
foreach ($vriData->getCrls() as $crl) {
    $module->addCrl($crl);
}

$tsModule = new \SetaPDF_Signer_Timestamp_Module_Rfc3161_Curl('http://example.com/tsa');
$tsModule->setDigest(\SetaPDF_Signer_Digest::SHA_256);
$signer->setTimestampModule($tsModule);

// let's keep the field name for later usage
$fieldName = $signer->addSignatureField()->getQualifiedName();
$signer->setSignatureFieldName($fieldName);

$signer->sign($module);

// Up to here the signature has no LTV information embedded.
// Now load the intermediate document, resolve the informaiton and embedded them

$document = \SetaPDF_Core_Document::loadByString($tempWriter, $writer);

// reuse the previous result to avoid duplicate information
$vriData = $collector->getByFieldName(
    $document, $fieldName, Collector::SOURCE_OCSP_OR_CRL, null, null, $vriData
);

$dss = new DocumentSecurityStore($document);
$dss->addValidationRelatedInfoByFieldName(
    $fieldName,
    $vriData->getCrls(),
    $vriData->getOcspResponses(),
    $vriData->getCertificates()
);

$document->save()->finish();