Create digital signatures for PDF documents with PHP

SetaPDF-Signer

Digital sign PDF documents with PHP

SetaPDF-Signer meets Fortify

This demo shows you an integration of the SetaPDF-Signer component as a server component for Fortify.

Fortify enables cross-browser usage of local certificates and smart cards.

While Fortify handles the signature part on the client side, the SetaPDF-Signer component handles all PDF related 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.

We created this demo in vanilla ES6 JavaScript to have as less dependencies and make it as simple as possible. To get details about the used dependencies which are not shown here, just check the same demo in an evaluation version or licensed package. 

<!DOCTYPE html>
<html lang="en">
<head>
    <meta charset="UTF-8">
    <meta name="viewport" content="width=device-width, initial-scale=1.0">
    <meta http-equiv="X-UA-Compatible" content="ie=edge">
    <title>SetaPDF-Signer meets Fortify</title>
    <style>
        body {
            font-family: Tahoma,Verdana,Segoe,sans-serif;
            font-size: 14px;
        }

        #signatureControlsPanel {
            margin-top: 1em;
        }
    </style>
</head>
<body>

<div id="loading">Loading...</div>
<div id="outdated" style="display:none;">Your browser is outdated.</div>
<div id="fortifyNotReachable" style="display:none;">Fortify is not running. <a href="https://fortifyapp.com/" target="_blank">Install</a> and start the app on your computer and reload this demo.</div>
<div id="challengeExchange" style="display:none;">Please compare this pin <b>{pin}</b> with the one that Fortify displays and confirm.</div>
<div id="notLoggedIn" style="display: none;">You're not logged in with Fortify.</div>

<div id="signatureControlsPanel" style="display: none;">

    <select id="providersSelect"><option>Loading...</option></select>
    <select id="certificatesSelect"><option>Loading...</option></select>
    <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 />

    <button id="signBtn" disabled="disabled">Sign Dummy File</button>
    <button id="downloadBtn" disabled="disabled">Download</button>

    <script type="text/javascript">
        if (!window.Promise) {
            document.getElementById('outdated').style.display = ''

        } else {
            
            document.addEventListener("DOMContentLoaded", function(event) {
                function loadScript(src) {
                    return new Promise(function(resolve, reject) {
                        var script = document.createElement('script');
                        script.src = src;
                        script.type = 'text/javascript';
                        script.onload = resolve.bind(null, true);
                        script.onerror = reject;
                        document.head.appendChild(script);
                    });
                }

                loadScript('/js/asmcrypto.min.js')
                    .then(function () {return loadScript('/js/elliptic.js')})
                    .then(function () {return loadScript('/js/webcrypto-liner.shim.min.js')})
                    .then(function () {return loadScript('/js/protobuf.min.js')})
                    .then(function () {return loadScript('/js/webcrypto-socket.min.js')})
                    .then(function () {return loadScript('/js/main.js')})
                    .catch(function(e) {
                        document.getElementById('loading').style.display = 'none';
                        document.getElementById('outdated').style.display = '';
                        console.error(e);
                    });
            });
        }
    </script>
</div>

</body>
</html>
(function() {
    // Some helper functions:
    function show(id, replacements) {
        replacements = replacements || {};
        let e = document.getElementById(id);
        if (!e.htmlTpl) {
            e.htmlTpl = e.innerHTML;
        }

        if (Object.keys(replacements).length > 0) {
            let tpl = e.htmlTpl;
            for (let k in replacements) {
                if (!replacements.hasOwnProperty(k)) {
                    continue;
                }

                let v = replacements[k];
                tpl = tpl.replace('{' + k + '}', v);
            }
            e.innerHTML = tpl;
        }

        e.style.display = '';
    }

    function hide(id) {
        document.getElementById(id).style.display = 'none';
    }

    // some helper functions to work with typed arrays
    function toHex(buffer) {
        let buf = new Uint8Array(buffer),
            splitter = "",
            res = [],
            len = buf.length;

        for (let i = 0; i < len; i++) {
            let char = buf[i].toString(16);
            res.push(char.length === 1 ? "0" + char : char);
        }
        return res.join(splitter);
    }

    function fromHex(hexString) {
        let res = new Uint8Array(hexString.length / 2);
        for (let i = 0; i < hexString.length; i = i + 2) {
            let c = hexString.slice(i, i + 2);
            res[i / 2] = parseInt(c, 16);
        }
        return res.buffer;
    }

    // we need some ajax
    function postRequest(url, params) {
        return new Promise(function(resolve, reject) {
            let xhr = new XMLHttpRequest();
            xhr.open("POST", url, true);
            xhr.onreadystatechange = () => {
                if (xhr.readyState === 4) {
                    if (xhr.status === 200) {
                        resolve(xhr.responseText);
                    } else {
                        reject(xhr, xhr.status);
                    }
                }
            };

            xhr.onerror = (() => reject(xhr, xhr.status));
            xhr.send(params);
        });
    }

    // the main function
    async function main() {
        let ws = new WebcryptoSocket.SocketProvider({
            storage: await WebcryptoSocket.BrowserStorage.create(),
        });

        // Checks if end-to-end session is approved
        let handleChallenge = async () => {
            if (!await ws.isLoggedIn()) {
                const pin = await ws.challenge();
                // show PIN
                show('challengeExchange', {pin: pin});
                // ask to approve session
                try {
                    await ws.login();
                } catch (e) {
                    if (confirm('Challenge was not accepted. Retry?')) {
                        await handleChallenge();
                    }
                }
                hide('challengeExchange');
            }
        };

        let providersSelect = document.getElementById('providersSelect'),
            certificatesSelect = document.getElementById('certificatesSelect'),
            signBtn = document.getElementById('signBtn'),
            downloadBtn = document.getElementById('downloadBtn');

        signBtn.disabled = true;
        downloadBtn.disabled = true;

        let init = () => {
            show('signatureControlsPanel');
            ws.cardReader
                .on("insert", () => updateProviders())
                .on("remove", () => updateProviders());
            updateProviders();
        };

        let updateProviders = async () => {
            const info = await ws.info();
            let selected = false;
            let currentProviderId = providersSelect.value;

            providersSelect.length = 0;

            if (!info.providers.length) {
                const option = document.createElement("option");
                option.textContent = "No providers";
                option.setAttribute("value", "");
                option.disabled = true;
                providersSelect.appendChild(option);
                providersSelect.dispatchEvent(new Event('change'));
                return;
            }

            for (const provider of info.providers) {
                const option = document.createElement("option");
                option.setAttribute("value", provider.id);
                option.textContent = provider.name;
                if (currentProviderId === provider.id) {
                    option.setAttribute("selected", "selected");
                    selected = true;
                }
                providersSelect.appendChild(option);
            }

            if (!selected) {
                providersSelect.firstElementChild.setAttribute("selected", "selected");
            }

            providersSelect.dispatchEvent(new Event('change'));
        };

        providersSelect.addEventListener('change', () => updateCertificates());

        let certs, provider;
        let updateCertificates = async () => {
            if (providersSelect.value === '') {
                certificatesSelect.length = 0;
                const option = document.createElement("option");
                option.textContent = "No certificates";
                option.setAttribute("value", "");
                option.disabled = true;
                certificatesSelect.appendChild(option);

                return;
            }

            provider = await ws.getCrypto(providersSelect.value);
            if (!(await provider.isLoggedIn())) {
                try {
                    await provider.login();
                } catch (e) {
                    // you may map e.code to a more meaningful message. A list of codes is available
                    // here: https://github.com/PeculiarVentures/fortify-web/blob/master/src/sagas/error.js
                    alert(e.message);
                    providersSelect.length = 0;
                    await updateProviders();
                    return;
                }
            }

            certs = [];
            let certIds = await provider.certStorage.keys();
            certIds = certIds.filter((id) => {
                const parts = id.split("-");
                return parts[0] === "x509";
            });

            let keyIds = await provider.keyStorage.keys();
            keyIds = keyIds.filter((id) => (id.split("-")[0] === "private"));

            const extractCommonName = (name) => {
                let reg = /CN=([^,]+),?/i,
                    res = reg.exec(name);
                return res ? res[1] : "Unknown";
            };

            for (const certId of certIds) {
                for (const keyId of keyIds) {
                    if (keyId.split("-")[2] === certId.split("-")[2]) {
                        try {
                            const cert = await provider.certStorage.getItem(certId);
                            certs.push({
                                id: certId,
                                item: cert,
                                name: extractCommonName(cert.subjectName),
                                pem: await provider.certStorage.exportCert('pem', cert),
                                privateKey: await provider.keyStorage.getItem(keyId)
                            });
                        } catch (e) {
                            console.error(`Cannot get certificate ${certId} from CertificateStorage. ${e.message}`);
                        }
                    }
                }
            }

            const now = new Date();
            certs = certs
                .filter((cert) => (cert.item.notBefore < now && now < cert.item.notAfter))
                .sort((a, b) => (a.name.localeCompare(b.name, undefined, {sensitivity: 'base'})));

            certificatesSelect.length = 0;
            certs.forEach((cert, index) => {
                const option = document.createElement("option"),
                      issuer = extractCommonName(cert.item.issuerName);

                option.setAttribute("value", index);
                option.textContent = cert.name + ' (' + issuer + '; '
                    + ' not before:' + cert.item.notBefore.toLocaleString() + '; '
                    + ' not after:' + cert.item.notAfter.toLocaleString() + ')';
                certificatesSelect.appendChild(option);
            });

            signBtn.disabled = (certs.length === 0);
        };

        let lastId = null;
        let sign = async () => {
            let cert = certs[certificatesSelect.value];

            try {
                let startResponseText = await postRequest(
                    'controller.php?action=start',
                    JSON.stringify({
                        certificate: cert.pem,
                        useAIA: document.getElementById('useAIA').checked,
                        useTimestamp: document.getElementById('useTimestamp').checked
                    })
                );
                let startJson = JSON.parse(startResponseText),
                    privateKey = cert.privateKey;

                const message = fromHex(startJson.dataToSign);
                const alg = {
                    name: privateKey.algorithm.name,
                    hash: "SHA-256",
                };

                let signature = await provider.subtle.sign(alg, privateKey, message);
                let completeResponseText = await postRequest(
                    'controller.php?action=complete',
                    JSON.stringify({signature: toHex(signature)})
                );
                let completeJson = JSON.parse(completeResponseText);
                lastId = completeJson.id;
                downloadBtn.disabled = false;
                window.open('controller.php?action=download&id=' + lastId);
            } catch (error) {
                console.info(error);
                alert('An error occured.');
            }
        };

        downloadBtn.addEventListener('click', () => window.open('controller.php?action=download&id=' + lastId));
        signBtn.addEventListener('click', () => sign());

        ws.connect("127.0.0.1:31337")
            .on("error", (e) => {
                hide('loading');
                show('fortifyNotReachable');
                console.error(e);
            })
            .on("listening", (e) => {
                hide('loading');

                handleChallenge()
                .then(() => {
                    return ws.isLoggedIn();
                })
                .then((isLoggedIn) => {
                    // was it successfully?
                    if (!isLoggedIn) {
                        show('notLoggedIn');
                        return;
                    }

                    init();
                }, (error) => {
                    console.error(error)
                });
            });
    }

    //noinspection JSIgnoredPromiseFromCall
    main();
})();
<?php
error_reporting(E_ALL);
ini_set('display_errors', '0');

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':
        if (isset($_SESSION['tmpDocument'])) {
            @unlink($_SESSION['tmpDocument']->getWriter()->getPath());
        }

        $data = json_decode(file_get_contents('php://input'));
        if (!isset($data->certificate)) {
            throw new Exception('Missing certificate!');
        }

        // 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($data->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));

        unset($_SESSION['tsModul']);
        $signer->setSignatureContentLength(6000 + $extraLength);

        // 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);
            }
        }

        // 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
        );

        $_SESSION['module'] = $module;

        // prepare the response
        $response = [
            'dataToSign' => SetaPDF_Core_Type_HexString::str2hex(
                $module->getDataToSign($_SESSION['tmpDocument']->getHashFile())
            )
        ];

        // 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();
        }

        $data->signature = SetaPDF_Core_Type_HexString::hex2str($data->signature);

        // 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($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);
        // clean up temporary file
        unlink($_SESSION['tmpDocument']->getWriter()->getPath());

        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
        ];
    }
}