Create digital signatures for PDF documents with PHP

SetaPDF-Signer

Digital sign PDF documents with PHP

SetaPDF-Signer meets Lacuna Web PKIĀ 

Lacuna Web PKIThis demo shows you an integration of the SetaPDF-Signer component as the server component for the Lacuna Web PKI.

Lacuna Web PKI is a browser plugin for performing signatures using X.509 certificates compatible with all major browsers and operating systems.

While the Lacuna Web PKI browser plugin handles the signature part on the client side, the SetaPDF-Signer component handles all PDF specifc parts in PHP on the server side. This combination allows you to use client side certificates (either a software-based or a certificate based on a cryptographic device such as a smartcard or USB token) while the final signature container is assembled and embedded into the PDF file on the server side by the SetaPDF-Signer component.

<html>
<head>
    <title>SetaPDF-Signer meets Lacuna Web PKI</title>
    <script type="text/javascript"
            src="https://cdn.jsdelivr.net/jquery/3.2.1/jquery.min.js"
            integrity="sha256-hwg4gsxgFZhOsEEamdOYGBf13FyQuiTwlAQgxVSNgt4="
            crossorigin="anonymous"
    ></script>
    <script type="text/javascript"
            src="https://cdn.jsdelivr.net/jquery.blockui/2.70.0/jquery.blockUI.min.js"
            integrity="sha256-9wSYpoBdTOlj3azv4n74Mlb984+xKfTS7dhcYRqSqMA="
            crossorigin="anonymous"
    ></script>
    <script type="text/javascript"
            src="https://get.webpkiplugin.com/Scripts/LacunaWebPKI/lacuna-web-pki-2.6.1.js"
    ></script>
    <style>
        body {
            font-family: Tahoma,Verdana,Segoe,sans-serif;
            font-size: 14px;
        }

        #signatureControlsPanel {
            margin-top: 1em;
            display: none;
        }
        #blockPanel {
            display: none;
            padding: 0.5em;
        }
    </style>
    <script>
        // This example is based on https://webpki.lacunasoftware.com/#/Documentation#improved-examples
        // It is changed, so that the backend is the SetaPDF-Signer component.
        // It signs the hash and no data.

        // ------------------------------------------------------------------------------------------
        // LacunaWebPKI: complete example (with jQuery and extra UI components)

        var pki = new LacunaWebPKI({
            "format": 2,
            "allowedDomains": [
                "setasign.com",
                "www.setasign.com",
                "manuals.setasign.com",
                "customers.setasign.com",
                "*.setasign.local"
            ],
            "homologDomains": [
                "ip4:10.0.0.0/8",
                "ip4:127.0.0.0/8",
                "ip4:172.16.0.0/12",
                "ip4:192.168.0.0/16"
            ],
            "productLevel": "Standard",
            "expiration": "2022-04-25 00:00:00Z",
            "signature": "oS1UT77Fug2jOwrKWKixI+2lDBnXCNoqioj+8xaWtrDCkvUgpv0ZP9li3vGvKgtahmAC+PCuYlwK9Bxiad5cNgIfTKT3LjxRy+J7gwHBVpIrTiY7gIFSU0kIMAwlQKS89QlaSbSpKrGO769HwvgUppqgRM+1xwb1WwZA2zU6vHnW2lmL9u8He3QDJCfsqn5/KFEr6vHA57lxl4cIA4+8rBsosAHhvTe7mMjpi76in2yv3c/+urVpMZKbHDzyehivM1iLeAPH1b7QwVgxzTGb4swo18e8nVhnEsZDsKHAl0U0VksPL4MZY/+hmJ6jaLCH0h5e1+iIH8ULdZKCU68+rA=="
        });

        function start() {
            log('Checking component installation ...');
            blockUI.start('Initializing Web PKI ...');
            pki.init({
                ready: onWebPkiReady,
                notInstalled: onWebPkiNotInstalled,
                defaultError: onWebPkiError
            });
        }

        function onWebPkiError(message, error, origin) {
            log('Web PKI error originated at ' + origin + ': ' + error);
            blockUI.message('Web PKI error: ' + message);
            $('.blockOverlay').click(blockUI.stop);
        }

        function onWebPkiNotInstalled (status, message) {
            log('Installation NOT ok: ' + message);
            blockUI.message(message + '<br/><br/>Click <a href="#" id="redirectToInstallPageLink">here</a> to go to installation page.');
            $('#redirectToInstallPageLink').click(function() {
                pki.redirectToInstallPage();
            });
            $('.blockOverlay').click(blockUI.stop);
        }

        function onWebPkiReady () {
            log('Component ready.');
            blockUI.message('Loading certificates ...');
            loadCertificates();
        }

        function refresh() {
            blockUI.start('Loading certificates ...');
            loadCertificates();
        }

        function loadCertificates() {
            log('Listing certificates ...');
            pki.listCertificates().success(function (certificates) {
                log('Certificates listed.');
                var select = $('#certificateSelect');
                select.empty();
                $.each(certificates, function() {
                    select.append(
                        $('<option />')
                            .val(this.thumbprint)
                            .text(this.subjectName + ' (issued by ' + this.issuerName + ')')
                    );
                });
                $('#signatureControlsPanel').show();
                blockUI.stop();
            });
        }

        // This function is called when the user clicks the button "Sign Dummy File"
        function signFile() {
            blockUI.start('Signing ... (step 1/4)');
            // The first thing we'll do is read the certificate
            var selectedCertThumb = $('#certificateSelect').val();
            log('Reading certificate ...');
            pki.readCertificate(selectedCertThumb).success(onReadCertificateCompleted);
        }

        // This function is the callback for when the readCertificate operation is completed
        function onReadCertificateCompleted(certificate) {
            log('Certificate binary encoding read, sending to server ...');
            blockUI.message('Signing ... (step 2/4)');
            // Now that we have acquired the certificat, we'll send that to the server using Ajax
            $.ajax({
                url: 'controller.php?action=start',
                method: 'POST',
                contentType: 'application/json',
                data: JSON.stringify({
                    certificate: certificate,
                    useAIA: $('#useAIA').prop('checked'),
                    useTimestamp: $('#useTimestamp').prop('checked')
                }),
                dataType: 'json',
                success: onSignatureStartCompleted
            });
        }

        // This function is the callback for when the server replies back with the "to-sign-hash"
        // and digest algorithm oid.
        function onSignatureStartCompleted(response) {
            log('Received response from server with bytes to sign and digest algorithm');
            log('hashToSign: ' + response.hashToSign);
            log('digestAlgorithmOid: ' + response.digestAlgorithmOid);
            log('Signing ...');
            blockUI.message('Signing ... (step 3/4)');
            pki.signHash({
                thumbprint: $('#certificateSelect').val(),
                hash: response.hashToSign,
                digestAlgorithm: response.digestAlgorithmOid
            }).success(onSignDataCompleted);
        }

        // This function is the callback for when the signHash operation is completed
        function onSignDataCompleted(signature) {
            log('Signature completed, submitting to server ...');
            blockUI.message('Signing ... (step 4/4)');
            // We send the signature result back to the server, which will then use
            // its server-side SDK to assemble the signed PDF file
            $.ajax({
                url: 'controller.php?action=complete',
                method: 'POST',
                contentType: 'application/json',
                data: JSON.stringify({
                    signature: signature
                }),
                dataType: 'json',
                success: onSignatureCompleteCompleted
            });
        }

        // This function is the callback for when the server replies back signalling that
        // the signature process is completed and we may download the signed PDF file.
        function onSignatureCompleteCompleted(result) {
            blockUI.message('Click <a href="controller.php?action=download&id=' + result.id + '">here</a> to download the signed PDF.');
            $('.blockOverlay').click(blockUI.stop);
        }

        function log(message) {
            if (window.console) {
                window.console.log(message);
            }
        }

        var blockUI = new function() {
            var start = function (msg) {
                if (msg) {
                    message(msg);
                } else {
                    message('Loading ...');
                }
                $.blockUI({ message: $('#blockPanel') });
            };

            var message = function (msg) {
                $('#blockPanel').html(msg);
            };

            var stop = function() {
                $.unblockUI();
            };

            this.start = start;
            this.message = message;
            this.stop = stop;
        };

        $(function() {
            $('#refreshButton').click(refresh);
            $('#signFileButton').click(signFile);
            start();
        });

    </script>
</head>
<body>
<div id="signatureControlsPanel">
    <select id="certificateSelect"></select>
    <button id="refreshButton" type="button">Refresh</button><br/>
    <input type="checkbox" name="useAIA" id="useAIA" checked="checked"/><label for="useAIA">Embedded certificates fetched from the <a href="http://www.pkiglobe.org/auth_info_access.html" target="_blank">AIA extension</a> (only HTTP, .cer/.der (no .p7c support), no validation is done).</label><br />
    <input type="checkbox" name="useTimestamp" id="useTimestamp" checked="checked" /><label for="useTimestamp">Embedded timestamp if adobe timestamp extension is available in certificate.</label><br />
    <br/>
    <button id="signFileButton" type="button">Sign Dummy File</button>
</div>
<div id="blockPanel">
</div>
</body>
</html>
<?php
/**
 * This is a simple demo showing you how you can create the signature value in an asyncronous workflow (e.g. through a
 * browser plugin).
 *
 * This file is the PHP backend which receives the signers public certificate, prepares the PDF document, returns the
 * hash and embedded a signature based on that hash back into the signature container and PDF.
 */
if (!isset($_GET['action'])) {
    die();
}

// require SetaPDF
require_once __DIR__ . '/library/SetaPDF/Autoload.php';
// we use a helper class for some "magic"
require_once __DIR__ . '/X509Helper.php';

date_default_timezone_set('Europe/Berlin');

$fileToSign = __DIR__ . '/demos/SetaPDF/_files/pdfs/tektown/Laboratory-Report.pdf';

// for demonstration purpose we use a session for state handling
// in a production environment you may use a more reasonable solution
session_start();

// a simple "controller":
switch ($_GET['action']) {
    // This action expects the certificate of the signer.
    // It prepares the PDF document accordingly.
    case 'start':
        $data = json_decode(file_get_contents('php://input'));
        if (!isset($data->certificate)) {
            die();
        }

        // load the PDF document
        $document = SetaPDF_Core_Document::loadByFilename($fileToSign);
        // create a signer instance
        $signer = new SetaPDF_Signer($document);
        // create a module instance
        $module = new SetaPDF_Signer_Signature_Module_Pades();
        $module->setDigest(SetaPDF_Signer_Digest::SHA_256);
        // pass the user certificate to the module
        $module->setCertificate(
            "-----BEGIN CERTIFICATE-----\n" . trim(chunk_split($data->certificate, 65)) . "\n-----END CERTIFICATE-----"
        );

        // create a helper instance
        $cert = new X509Helper($module->getCertificate());
        $extraCerts = [];
        // get issuer certificates
        if (isset($data->useAIA) && $data->useAIA) {
            // The helper class allows us to resolve issuing certificates, which we add automatically
            $issuer = $cert->getIssuerCertificateByAuthorityInformationAccess();
            while ($issuer) {
                $issuerCert = new X509Helper($issuer);
                $extraCerts[] = $issuer;
                $issuer = $issuerCert->getIssuerCertificateByAuthorityInformationAccess();
            }
        }

        $module->setExtraCertificates($extraCerts);
        $extraLength = strlen(implode('', $extraCerts));
        $extraLength = ($extraLength % 2) === 1 ? $extraLength + 1 : $extraLength; // needs to be an even length

        // get timestamp information and use it
        if (isset($data->useTimestamp) && $data->useTimestamp) {
            // the helper also allows us to access the adobe timestamp extension
            $tsConfig = $cert->getAdobeTimestamp();
            // if it exists and the timestamp doesn't requires an authentication, we also embedded a timestamp
            if ($tsConfig && $tsConfig['version'] === 1 && $tsConfig['requiresAuth'] === false) {
                $_SESSION['tsModul'] = new SetaPDF_Signer_Timestamp_Module_Rfc3161_Curl($tsConfig['location']);
                $signer->setTimestampModule($_SESSION['tsModul']);
                $signer->setSignatureContentLength(14000 + $extraLength);
            } else {
                unset($_SESSION['tsModul']);
                $signer->setSignatureContentLength(6000 + $extraLength);
            }
        } else {
            unset($_SESSION['tsModul']);
            $signer->setSignatureContentLength(6000 + $extraLength);
        }

        // you may use an own temporary file handler
        $tempPath = SetaPDF_Core_Writer_TempFile::createTempPath();

        // prepare the PDF
        $_SESSION['tmpDocument'] = $signer->preSign(
            new SetaPDF_Core_Writer_File($tempPath),
            $module
        );

        // let's get the hash data
        $hashData = $module->getDataToSign($_SESSION['tmpDocument']->getHashFile());
        $_SESSION['module'] = $module;

        // prepare the response
        $response = [
            'hashToSign' => base64_encode(hash('sha256', $hashData, true)),
            'digestAlgorithmOid' => SetaPDF_Signer_Digest::getOid($module->getDigest())
        ];

        // send it
        header('Content-Type: application/json; charset=utf-8');
        echo json_encode($response);
        break;

    // This action embeddeds the signature in the CMS container
    // and optionally requests and embeds the time stamp
    case 'complete':
        $data = json_decode(file_get_contents('php://input'));
        if (!isset($data->signature)) {
            die();
        }

        // create the document instance
        $writer   = new SetaPDF_Core_Writer_String();
        $document = SetaPDF_Core_Document::loadByFilename($fileToSign, $writer);
        $signer   = new SetaPDF_Signer($document);

        // pass the signature to the signature modul
        $_SESSION['module']->setSignatureValue(base64_decode($data->signature));

        // get the CMS structur from the signature module
        $cms = (string) $_SESSION['module']->getCms();
        // add the timestamp (if available)
        if (isset($_SESSION['tsModul'])) {
            $signer->setTimestampModule($_SESSION['tsModul']);
            $cms = $signer->addTimeStamp($cms, $_SESSION['tmpDocument']);
        }

        // save the signature to the temporary document
        $signer->saveSignature($_SESSION['tmpDocument'], $cms);

        if (!isset($_SESSION['pdfs']['currentId'])) {
            $_SESSION['pdfs'] = ['currentId' => 0, 'docs' => []];
        } else {
            // reduce the session data to 5 signed files only
            while (count($_SESSION['pdfs']['docs']) > 5) {
                array_shift($_SESSION['pdfs']['docs']);
            }
        }

        $id = $_SESSION['pdfs']['currentId']++;
        $_SESSION['pdfs']['docs']['id-' . $id] = $writer;
        // send the response
        header('Content-Type: application/json; charset=utf-8');
        echo json_encode(['id' => $id]);
        break;

    // a download action
    case 'download':
        $key = 'id-' . (isset($_GET['id']) ? $_GET['id'] : '');
        if (!isset($_SESSION['pdfs']['docs'][$key])) {
            die();
        }

        $doc = $_SESSION['pdfs']['docs'][$key];

        header('Content-Type: application/pdf');
        header('Content-Disposition: attachment; filename="' . basename($fileToSign, '.pdf') . '-signed.pdf"');
        header('Expires: 0');
        header('Cache-Control: must-revalidate, post-check=0, pre-check=0');
        header('Pragma: public');
        header('Content-Length: ' . strlen($doc));
        echo $doc;
        flush();
        break;
}
<?php

/**
 * A helper class accessing specific extensions in X.509 certificates
 */
class X509Helper
{
    /**
     * The certificate
     *
     * @var SetaPDF_Signer_Asn1_Element
     */
    protected $certificate;

    /**
     * The constructor.
     *
     * @param string $certificate A PEM encoded string or path to a PEM encoded X.509 certificate.
     */
    public function __construct($certificate)
    {
        $this->certificate = SetaPDF_Signer_Signature_Module_Cms::getParsedCertificate($certificate);
    }

    /**
     * Get the TBSCertificate value.
     *
     * @return SetaPDF_Signer_Asn1_Element
     */
    private function getTBSCertificate()
    {
        return $this->certificate->getChild(0);
    }

    /**
     * Get an extension by its OID.
     *
     * @param $oid
     * @return false|SetaPDF_Signer_Asn1_Element
     */
    private function getExtension($oid)
    {
        $tbs = $this->getTBSCertificate();
        if ($tbs->getChild(0)->getIdent() !== "\xA0") {
            return false;
        }

        $version = ord($tbs->getChild(0)->getChild(0)->getValue());
        if ($version !== 2) {
            return false;
        }

        $extensions = [];

        $offset = 7;
        for (; $offset < $tbs->getChildCount(); $offset++) {
            if ($tbs->getChild($offset)->getIdent() === "\xA3") {
                $extensions = $tbs->getChild($offset)->getChild(0)->getChildren();
            }
        }

        foreach ($extensions as $extension) {
            $extnID = SetaPDF_Signer_Asn1_Oid::decode($extension->getChild(0)->getValue());
            if ($extnID === $oid) {
                return $extension;
            }
        }

        return false;
    }

    /**
     * Get the Authority Information Access extension.
     *
     * @param string $oid
     * @return false|SetaPDF_Signer_Asn1_Element
     */
    private function getAuthorityInformationAccess($oid)
    {
        // Authority Information Access
        $extension = $this->getExtension('1.3.6.1.5.5.7.1.1');
        if ($extension === false) {
            return false;
        }

        $accessDescriptions = SetaPDF_Signer_Asn1_Element::parse($extension->getChild(1)->getValue());

        $accessLocation = null;
        foreach ($accessDescriptions->getChildren() as $accessDescription) {
            $accessMethod = SetaPDF_Signer_Asn1_Oid::decode($accessDescription->getChild(0)->getValue());
            if ($oid === $accessMethod) {
                $accessLocation = $accessDescription->getChild(1)->getValue();
            }
        }

        if ($accessLocation === null) {
            return false;
        }

        return $accessLocation;
    }

    /**
     * Get the issuer certificate from the authority information access extension (if available).
     *
     * @see https://tools.ietf.org/html/rfc5280#page-50
     * @return false|string
     */
    public function getIssuerCertificateByAuthorityInformationAccess()
    {
        $location = $this->getAuthorityInformationAccess('1.3.6.1.5.5.7.48.2');
        // let's use HTTP(S) only
        if ($location === false || strpos($location, 'http') !== 0) {
            return false;
        }

        $curl = curl_init();
        curl_setopt($curl, CURLOPT_URL, $location);

        // return the transfer as a string
        curl_setopt($curl, CURLOPT_RETURNTRANSFER, 1);
        // follow a redirect
        curl_setopt($curl, CURLOPT_FOLLOWLOCATION, true);

        // $output contains the output string
        $result = curl_exec($curl);

        $httpCode = curl_getinfo($curl, CURLINFO_HTTP_CODE);

        // close curl resource to free up system resources
        curl_close($curl);

        if ($result === false || $httpCode !== 200) {
            return false;
        }

        if (!base64_decode($result, true)) {
            $result = base64_encode($result);
        }

        /**
         * TODO:
         *    Where the information is available via HTTP or FTP, accessLocation
         *    MUST be a uniformResourceIdentifier and the URI MUST point to either
         *    a single DER encoded certificate as specified in [RFC2585] or a
         *    collection of certificates in a BER or DER encoded "certs-only" CMS
         *    message as specified in [RFC2797].
         *
         * Also check for a CMS certs-only CMS container: https://tools.ietf.org/html/rfc2797#section-2.2
         */

        $cert = "-----BEGIN CERTIFICATE-----\n" .
            trim(chunk_split($result, 65)) .
            "\n-----END CERTIFICATE-----\n";

        try {
            SetaPDF_Signer_Signature_Module_Cms::getParsedCertificate($cert);
        } catch (InvalidArgumentException $e) {
            return false;
        }

        return $cert;
    }

    /**
     * Get the adobe timestamp extension data (if available).
     *
     * @return array|bool
     */
    public function getAdobeTimestamp()
    {
        // documented here http://www.adobe.com/devnet-docs/acrobatetk/tools/DigSig/oids.html#x-509-extension-oids
        $extension = $this->getExtension('1.2.840.113583.1.1.9.1');
        if ($extension === false) {
            return false;
        }

        $timestampUrl = SetaPDF_Signer_Asn1_Element::parse($extension->getChild(1)->getValue());

        $version = ord($timestampUrl->getChild(0)->getValue());
        $location = $timestampUrl->getChild(1)->getValue();
        $requiresAuth = false;
        if ($timestampUrl->getChild(2)) {
            $requiresAuth = !($timestampUrl->getChild(2)->getValue() === "\x00");
        }

        return [
            'version' => $version,
            'location' => $location,
            'requiresAuth' => $requiresAuth
        ];
    }
}